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.allin 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:
- For data that changes often, export
export const dynamic = "force-dynamic". - For data that changes occasionally, use
revalidatewith a sensible window. - For data that changes on user action, use
revalidateTagorrevalidatePathafter 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.tsxthat 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, andnot-found.tsxgive 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:
- Tag every meaningful fetch with
{ next: { tags: ['orders'] } }so a singlerevalidateTag('orders')after a mutation flushes exactly what changed — no more, no less. - Reach for
force-dynamicsparingly; an over-eagerforce-dynamicat the layout level silently opts every child page out of static rendering. - Set
revalidatein 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:
- 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.
- Move the leaf routes. Migrate a single read-only page, replacing
getServerSideProps/getStaticPropswith anasyncserver component that fetches inline. - Translate data-fetching idioms.
getStaticPropsbecomes a cachedfetch;getServerSidePropsbecomes a dynamic fetch orforce-dynamic;getStaticPathsbecomesgenerateStaticParams. - Rework
_appand_document. Their responsibilities split into the rootlayout.tsxand a provider tree. - 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