Next.js App Router: Patterns That Scale

Lessons from migrating large Next.js codebases — server components, caching, and streaming in practice.

The App Router is no longer experimental — it is the default for new Next.js apps and, increasingly, the answer for existing ones too. We have migrated four production codebases this year. Here are the patterns that held up and the pitfalls that caught us.

Server components are the mental shift

The biggest productivity win from the App Router comes from not having to build an API layer for read-only data. Fetch directly from your database in a server component. Your UI code becomes less code.

Default to server components. Add "use client" only where you need state, effects, or browser APIs.

Data fetching patterns that work

  • Parallel data: call Promise.all in a server component to fetch in parallel.
  • Sequential data: when a query depends on another, split into nested components and let Suspense stream them in.
  • Per-route data: put fetches in the route segment that uses them — not in layouts — so you do not break on navigation.

Caching: the part that bites everyone

Next.js caches aggressively by default. That is fine for marketing pages and painful for dashboards. The mental model we use:

  1. For data that changes often, export export const dynamic = "force-dynamic".
  2. For data that changes occasionally, use revalidate with a sensible window.
  3. For data that changes on user action, use revalidateTag or revalidatePath after the mutation.

Streaming and Suspense

Wrap slow server components in <Suspense> with a skeleton fallback. Users see the shell instantly, and slow data streams in as it arrives. This is the App Router's biggest perceived-performance win.

Server actions for mutations

Server actions replace most internal API routes for form submissions and mutations. Pair them with revalidateTag for cache invalidation. Avoid them for anything public or untrusted — stick to API routes there.

What does not work well yet

  • Very complex global state — client components with Zustand/Jotai still have a place.
  • Libraries that assume a browser environment in render (some chart libraries, some auth providers).
  • Middleware with heavy logic — keep it lean or performance suffers on every request.

Layouts, templates, and shared UI

The App Router's nested file-system routing is its quiet superpower. A layout.tsx wraps every route in its segment and — critically — persists across navigation, so a sidebar or nav bar never re-renders or loses scroll position when the user clicks through pages. Use that to your advantage:

  • Root layout holds <html>, fonts, and providers that must survive every navigation.
  • Nested layouts wrap a feature area — for example a /dashboard/layout.tsx that renders the dashboard chrome once and lets the child pages swap underneath it.
  • Templates (template.tsx) are the escape hatch when you actually want a fresh mount on every navigation — useful for enter animations or resetting per-page state.
  • Special files like loading.tsx, error.tsx, and not-found.tsx give you Suspense and error boundaries for free, scoped to the exact segment they live in.

The server-versus-client boundary in practice

The single most common mistake we see is marking a whole page "use client" because one button needs an onClick. That ships your entire subtree — and its data-fetching logic — to the browser. The fix is to push the boundary down: keep the page a server component, fetch data there, and extract just the interactive leaf into its own client component.

Think of "use client" as the edge of an island, not a switch for the whole page. The smaller the island, the less JavaScript your users download.

A server component can import and render a client component and pass it serialisable props (strings, numbers, plain objects, even server-fetched data). A client component cannot import a server component directly — but it can accept one as children, which is the pattern that keeps most layouts on the server while sprinkling interactivity through them. When something genuinely needs both worlds — a charting library that only runs in the browser, say — wrap it with next/dynamic and { ssr: false } rather than poisoning the whole tree with a client directive.

Caching, expanded

Caching in the App Router is really four overlapping caches: the Request Memoization cache (dedupes identical fetch calls within one render), the Data Cache (persists fetch results across requests), the Full Route Cache (stores rendered HTML for static routes), and the Router Cache (client-side, holds visited segments in memory). Most "why is my data stale?" bugs trace back to one of these, so it pays to know which one you are fighting. Our defaults:

  1. Tag every meaningful fetch with { next: { tags: ['orders'] } } so a single revalidateTag('orders') after a mutation flushes exactly what changed — no more, no less.
  2. Reach for force-dynamic sparingly; an over-eager force-dynamic at the layout level silently opts every child page out of static rendering.
  3. Set revalidate in seconds at the fetch or segment level for content that has a natural freshness window (a pricing page might be five minutes; a blog index might be an hour).

This is exactly the kind of nuance we build into the web development projects we ship, where a wrong caching default can mean a customer sees yesterday's invoice total.

Migrating from the Pages Router

The good news: the app/ and pages/ directories coexist in the same project, so you migrate incrementally instead of rewriting everything in one risky push. Our playbook, in order:

  1. Audit first. List your routes and sort them by how much they would benefit from server components — read-heavy, data-fetching pages go to the top.
  2. Move the leaf routes. Migrate a single read-only page, replacing getServerSideProps/getStaticProps with an async server component that fetches inline.
  3. Translate data-fetching idioms. getStaticProps becomes a cached fetch; getServerSideProps becomes a dynamic fetch or force-dynamic; getStaticPaths becomes generateStaticParams.
  4. Rework _app and _document. Their responsibilities split into the root layout.tsx and a provider tree.
  5. Save auth and middleware for last. These are where the sharp edges live; do them once the easy wins are banked and you understand your new boundaries.

Do not big-bang a migration. Move one route at a time, starting with read-heavy pages that benefit most from server components. Leave complex authenticated flows in Pages Router until you have a clear plan for auth and middleware. If your migration also touches AI features — RAG endpoints, streaming model responses — note that server actions and route handlers stream beautifully, and our AI & machine learning team has wired several of these into App Router apps.

Best practices we standardise on

  • Co-locate data fetching with the component that renders it; let Request Memoization dedupe the rest.
  • Stream above the fold fast and defer the slow parts behind <Suspense> instead of blocking the whole page.
  • Keep client components small, named, and at the leaves of the tree.
  • Validate every server-action input as if it were a public API — it effectively is.
  • Measure with the bundle analyzer; a creeping client bundle is the clearest signal a boundary drifted in the wrong direction.

Planning a build or a migration and want a second pair of eyes? Talk to our team — we have shipped this pattern across production codebases and can tell you quickly whether the App Router is the right move for yours.

Frequently Asked Questions

Do I have to rewrite my whole app to use the App Router?

No. The App Router lives in an app/ directory that coexists with the existing pages/ directory in the same project, so you can migrate one route at a time. We recommend starting with read-heavy, data-fetching pages that benefit most from server components and leaving complex authenticated flows in the Pages Router until last.

When should I use a client component instead of a server component?

Default to server components and add "use client" only where you need state, effects, browser APIs, or event handlers like onClick. Keep client components small and push the boundary down to the interactive leaf, rather than marking a whole page as a client component just because one button needs interactivity.

Why is my data stale after a mutation?

Next.js caches aggressively across several layers, including the Data Cache and the client-side Router Cache. After a mutation, call revalidateTag or revalidatePath to flush exactly what changed. Tagging your fetches lets a single revalidateTag invalidate just the affected data instead of forcing everything dynamic.

Are server actions a replacement for API routes?

Server actions replace most internal API routes for form submissions and mutations, and they pair naturally with revalidateTag for cache invalidation. However, for anything public or untrusted you should stick to route handlers, and you must validate every server-action input as if it were a public API, because it effectively is.

← Back to blog

Need Help With A Next.js Project?

Whether it is a greenfield build or a migration from Pages Router, we can help.

Talk To Our Team