The App Router is not just a new folder convention. It is a fundamentally different rendering model, and most production problems I see come from teams treating it like the Pages Router with extra steps.

The mental model shift

In the Pages Router, everything was a client bundle with server-side data injection. In the App Router, the server is the default and the client is opt-in.

A Server Component never ships to the browser. Its JavaScript weight is zero. Reach for "use client" only when you need interactivity, browser APIs, or state.

This inverts the question you ask while building. Instead of "how do I get data into this component?", you ask "does this component need to be interactive at all?"

Where the boundary belongs

Push client boundaries to the leaves of the tree. A page with one interactive button should not be a client component — the button should be.

app/products/page.tsx
import { getProducts } from "@/lib/products";
import { AddToCartButton } from "@/components/add-to-cart";
 
export default async function ProductsPage() {
  const products = await getProducts();
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name}
          <AddToCartButton id={p.id} />
        </li>
      ))}
    </ul>
  );
}

Only AddToCartButton hydrates. The list, the data fetching, the markup — all server-only.

Layouts are your caching strategy

Layouts do not re-render on navigation. That makes them the natural home for expensive, shared work:

Concern Where it belongs Why
Navigation shell Root layout Renders once, persists across routes
Auth session read Layout or page De-duplicated per request
Per-route data Page Re-fetched on navigation
Interactive widgets Leaf client components Minimal hydration

Streaming beats loading spinners

Wrap slow subtrees in Suspense and the shell renders instantly while data streams in:

app/dashboard/page.tsx
import { Suspense } from "react";
 
export default function Dashboard() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <SlowStats />
      </Suspense>
    </main>
  );
}

loading.tsx is just a route-level Suspense boundary. Use inline Suspense for finer control — one slow widget should not block the whole page.

A structure that survives growth

My checklist for App Router codebases past ~50 routes:

  • Server Components by default, client boundaries at the leaves
  • Data access isolated in lib/, never inline fetch in components
  • generateStaticParams for everything that can be known at build time
  • Route groups (marketing) / (app) to separate layout trees
  • Parallel routes only when genuinely needed — they add complexity

The App Router rewards teams that respect its model and punishes those who fight it. Ship less JavaScript, stream the rest, and let the server do what servers are good at.1

Footnotes

  1. Benchmarks in this post were run on Next.js 16 with Turbopack in production mode.