Next.js App Router Mental Model


The App Router is not just a new folder convention. It is a rendering model: a route is assembled from server-rendered structure, nested UI boundaries, streamed data, and small pieces of browser interactivity. Once that model clicks, many Next.js decisions stop feeling like framework trivia.
The important shift is this: a component is not automatically code that runs in the browser. In an App Router application, components render on the server by default. You choose the client boundary only when the browser needs to own state, events, or an API that exists only in the browser.
The Route Is a Composition
Consider a product route:
app/
products/
[slug]/
page.tsx
loading.tsx
error.tsx
layout.tsxWhen a visitor opens /products/walnut-desk, Next.js does not look for one giant page component and send it all to the browser. It composes the root layout, any nested layouts, the route page, and the matching loading or error boundary. The result can stream to the browser as work completes.
That composition is why colocating route-specific files matters. A loading.tsx is not a convention for its own sake; it is the pending UI for that segment. An error.tsx is the recovery boundary for that segment. A layout.tsx is the durable shell that can persist while child routes change.
Start on the Server
Server Components are the default because most page work is not interactive. Fetching a product, reading a database, checking server-only configuration, and rendering a heading do not require JavaScript to be shipped for that component.
// app/products/[slug]/page.tsx
import { notFound } from "next/navigation";
type ProductPageProps = {
params: Promise<{ slug: string }>;
};
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
const product = await getProductBySlug(slug);
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCart productId={product.id} />
</article>
);
}The page can access server-side resources. It can await data. It can render a client component such as AddToCart. What it cannot do is attach an onClick handler itself, because that would require the page component to become part of the client bundle.
Server Is the Default, Not a Limitation
Keeping a component on the server is often the simplest and fastest choice. Move to the client only at the smallest component that needs browser state or an event handler.
Use Client Components for Interaction
Add the "use client" directive at the top of a file only when the component needs hooks, event handlers, browser APIs, or client-side libraries. The directive creates a boundary: imports below it join the client bundle, while a Server Component can still render the client component and pass serializable props.
Choose the rendering boundary
Toggle the two component types to see the responsibility each side should own. This visual simulates the boundary; your real route still runs through Next.js.
Choose where this work belongs:
Server Components can read data and secrets before sending a rendered result to the browser. They cannot attach browser event handlers.
export default async function Page() {
const product = await getProduct();
return <AddToCart productId={product.id} />;
}The most common mistake is adding "use client" high in the tree to make one button work. That turns an entire layout or page into browser JavaScript and blocks server-only imports. Keep the interactive island small: pass it the data it needs, then let it manage only the interaction it owns.
Layouts Are Durable Boundaries
Layouts answer a different question from pages. A page owns the UI for a specific route. A layout owns UI that should wrap a set of routes: navigation, an account shell, an admin sidebar, or shared providers.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard-shell">
<DashboardNavigation />
<main>{children}</main>
</div>
);
}Do not turn a layout into a global state container by default. A root layout is a poor place for page-specific fetching or client state because every child inherits that choice. Put providers as close as possible to the routes that require them, and keep server-rendered structure outside client providers when possible.
A Product Page Walkthrough
A practical route usually separates responsibilities like this:
- The page fetches the product and decides whether the route exists.
- Server-rendered components create the title, price, description, and SEO-relevant content.
- A client component owns quantity selection and the add-to-cart event.
- A route-level loading component gives the visitor feedback while the server work is pending.
- An error boundary offers a recovery path when the data operation fails.
This does not mean server and client code are enemies. The boundary is a contract. The server prepares trusted data and structure; the client receives the smallest serializable input necessary to make the experience responsive.
Common Boundary Mistakes
Fetching in a client effect by reflex. If data is required to render the route, start on the server. Client fetching is still useful for live updates, polling, and interaction-driven requests, but it should be a deliberate choice.
Passing server-only objects to a client component. Database connections, functions, class instances, and secrets cannot cross the boundary. Pass IDs, strings, numbers, plain objects, and arrays instead.
Treating use client as a per-function switch. It applies to the file and its client-side dependency graph. Put it in a focused leaf component rather than a broad route shell.
Using a layout when a page component is enough. A layout is durable by design. If a view should reset for every route, keep it in the page.
A Practical Decision Process
When you add a component, ask these questions in order:
- Does it need an event handler, local state, an effect, or a browser API? If not, keep it on the server.
- Does it need server-only data or credentials? If yes, it must remain on the server.
- Can a small client component receive serializable data from a server parent? If yes, isolate the interaction there.
- Should the UI persist across several routes? If yes, consider a layout.
- What should the visitor see while the segment loads or fails? Add route-level loading and error UI intentionally.
What Is Next
With the App Router mental model in place, the next post goes deeper into Server and Client Component boundaries: what crosses them, how bundle size changes, and how to design props that keep the relationship clean.
Key Takeaways
- App Router routes are composed from nested route files and rendering boundaries.
- Server Components are the default place for data access and non-interactive UI.
"use client"creates a client bundle boundary; keep it low in the component tree.- Layouts are durable shells, while pages own route-specific content.
- Good App Router design is mostly about placing responsibilities on the right side of the boundary.