Next.js App Router in Production: 18 Months of Lessons
by Maven Team, Software Development
We migrated our first client application to the Next.js App Router in early 2025, when the dust from the initial release had barely settled. Since then we have been running it in production across client projects and have accumulated a catalogue of lessons that we wish someone had written down before we started.
This is that article. It is not a tutorial. It assumes you have read the Next.js docs and are either running App Router in production or about to. These are the things that will catch you out.
The caching model is the hardest thing to internalise
If there is one thing that has caused the most confusion, debugging time, and genuine production incidents across all our App Router projects, it is the caching model.
In the Pages Router, the mental model was simple: getStaticProps caches, getServerSideProps does not. App Router replaced this with a layered system — React cache, the fetch cache, the full-route cache, and the router cache — that interact in non-obvious ways. Making matters worse, the defaults changed significantly between Next.js 13, 14, and 15.
In Next.js 13 and 14, fetch was cached by default. A fetch() call in a Server Component would be deduplicated and cached unless you explicitly opted out with { cache: 'no-store' }. In Next.js 15, the default flipped: fetch is uncached by default and you opt in to caching with { next: { revalidate: 3600 } }.
This matters because if you are running Next.js 14 and plan to upgrade, you will likely find that pages which were previously static are now dynamic — and your Vercel bill or Lambda cold-start latency will reflect that.
What we do now: We annotate every fetch call with an explicit cache option. No implicit defaults. If data should be fresh on every request, we write { cache: 'no-store' }. If it can be stale, we write { next: { revalidate: 900 } }. This makes the caching behaviour visible in the code and survives version upgrades without surprise.
For mutations, we use revalidateTag() and revalidatePath() from next/cache. Tags are more surgical: tag a fetch with ['products'] and call revalidateTag('products') after a write. This keeps your cache hot everywhere except where you just changed something.
The one thing we still find confusing: unstable_cache. It is the right tool for caching non-fetch data (database queries, third-party SDK calls), but the API feels like it was designed in a hurry and the tags option has bitten us when tags were not invalidated as expected. Our rule is to write a thin wrapper around it rather than calling it directly in components.
Server Components require a different mental model, not just different syntax
The marketing pitch for Server Components is that they let you move data fetching to the server. The practical reality is that they require you to rethink where logic lives, not just where data is fetched.
The instinct when you first start using Server Components is to make everything a Server Component, hit a database in every component, and let React figure out the rest. This works for simple cases and falls apart quickly in complex ones.
The waterfall problem: If you have a Server Component that fetches a user, renders a profile header, and renders a list of that user's orders — and the orders component itself fetches order details for each item — you have a server-side waterfall. Each fetch waits for the previous one to resolve. In a Pages Router application, you would have noticed this because everything lived in getServerSideProps and you would have written Promise.all. In Server Components, it hides in your component tree until you profile your route and see 800ms of sequential database calls.
What we do: Lift data fetching up. One parent Server Component fetches all the data a route needs — using Promise.all or parallel awaits — and passes it down as props. Server Components lower in the tree handle rendering, not fetching. This is closer to how Pages Router worked and avoids the waterfall almost entirely.
The exception is components that are used across many different routes and need their own data. For those, we use React.cache() to deduplicate — if two Server Components in the same render both call a wrapped function with the same arguments, the underlying function runs once.
Client Component islands: The biggest architectural decision in any App Router project is where to draw the boundary between Server and Client Components. Our heuristic: everything is a Server Component until it needs interactivity (event handlers, hooks, browser APIs). When you need a Client Component, push the boundary as far down the tree as possible. A common mistake is marking a layout or a large parent as 'use client' because one child needs a click handler — this pulls all of that component's data fetching back to the client unnecessarily.
Layouts do not re-render — and this will surprise you
In the Pages Router, navigating between pages caused a full component re-render including your layout. In App Router, layouts persist across navigation. The layout component renders once and stays mounted.
This is intentional and usually great for performance. It is also a source of bugs that are genuinely hard to track down.
The most common one we hit: data fetched in a layout does not refresh when the user navigates to a child route. If your layout fetches the current user's subscription status and displays different navigation items based on it, that data is stale after any subsequent navigation — even if the user just upgraded their plan. The layout does not know to refetch.
The fix depends on what you need. For data that must always be current, move it to the page level rather than the layout. For data that can tolerate some staleness, revalidatePath('/', 'layout') after mutations forces the layout to re-render on next navigation. For real-time requirements, move to a Client Component with useSWR or react-query.
Route groups solve a different but related problem. If you need different layouts for different sections of your app — the marketing site, the authenticated dashboard, the admin area — route groups let you nest layouts without affecting the URL structure. We use (marketing), (app), and (admin) route groups on almost every project now. It keeps the layout tree clean and means layout changes in one section cannot accidentally affect another.
Error boundaries are not optional — design them upfront
Pages Router had _error.tsx. App Router has error.tsx, global-error.tsx, and not-found.tsx — each serving a distinct purpose, each with different constraints.
error.tsx catches errors within a route segment and its children. It must be a Client Component (it uses the reset function, which is a React mechanism). It does not catch errors in the same segment's layout — for that, you need an error.tsx in the parent segment.
global-error.tsx catches errors in the root layout. Because the root layout includes your <html> and <body> tags, global-error.tsx must render its own HTML shell. This surprises most developers the first time they see a styled error page in development and an unstyled one in production — the styles live in the root layout, which global-error.tsx has replaced.
not-found.tsx is triggered by the notFound() function, which you call explicitly in Server Components when a resource does not exist. This is cleaner than throwing an error, but you have to remember to call it.
What we do: Design all three error UI components before starting feature work, not after. Make sure global-error.tsx has its own minimal stylesheet or inline styles. Test error boundaries by throwing explicitly in development — do not wait to discover gaps in production.
Route Handlers are not a direct replacement for API Routes
Route Handlers (app/api/route.ts) look like API Routes but behave differently in a few important ways.
GET Route Handlers are statically rendered by default in Next.js 14 (cached), unless you access dynamic data like cookies() or headers(). This catches most teams out: you write a GET /api/products handler expecting fresh data on every request, and you get a cached response that never updates. The fix is export const dynamic = 'force-dynamic' at the top of the file, or just using cookies() or headers() anywhere in the handler, which forces dynamic rendering automatically.
POST, PUT, DELETE, and PATCH handlers are always dynamic. This is usually what you want, but it is worth knowing that the asymmetry exists.
The other difference: you cannot use Route Handlers as a replacement for Server Actions for form mutations. Server Actions run on the server and can be called from Client Components without going through an explicit HTTP request — they handle CSRF protection, progressive enhancement, and optimistic updates as a unit. Route Handlers require you to write the fetch on the client side. For mutations triggered by forms or buttons, Server Actions are almost always the better choice.
The next/navigation shift catches teams mid-project
If you are migrating from the Pages Router, you will have muscle memory for useRouter from next/router. App Router uses next/navigation, and the APIs are different in ways that are easy to miss.
useRouter in next/navigation no longer has .query. For URL parameters, use useParams(). For search parameters, use useSearchParams(). For the current pathname, use usePathname().
The useSearchParams() hook requires the component to be wrapped in a <Suspense> boundary — Next.js will throw an error if it is not, because reading search params opts the component into client-side rendering. This is a runtime error, not a build-time error, so it is easy to miss in development if your URLs do not have query strings, and then hit in production when a user arrives via a search result URL.
Our convention: any component that uses useSearchParams() is always co-located with a <Suspense> wrapper in the same file, so they cannot be accidentally separated.
Testing Server Components is still harder than it should be
The official recommendation for testing Server Components is to use End-to-End tests with Playwright or Cypress rather than unit tests. This is pragmatic advice but it shifts a lot of weight to slow, infrastructure-dependent tests.
For Server Components we care about — ones with complex conditional rendering logic — we have had success extracting the pure logic into plain functions that we unit test independently, and testing the component itself only via Playwright. This is not elegant, but it works.
For Route Handlers, we use next/test-utils where it exists and fall back to supertest against a local server. For Server Actions, the testing story is the weakest part of the ecosystem: we have ended up testing them via Playwright as well, accepting the slower feedback loop.
The practical implication: Your Playwright test suite will be larger and more important in an App Router project than in a Pages Router project. Budget for it accordingly. We estimate roughly one Playwright test per user journey, and we run them in CI on every PR — not just before deployment.
Non-Vercel deployment has improved, but requires attention
The App Router runs perfectly well outside of Vercel, but you have to understand the deployment model.
The standalone output mode (output: 'standalone' in next.config.mjs) produces a self-contained Node.js server that you can containerise and deploy to ECS, Cloud Run, or any Kubernetes cluster. ISR (Incremental Static Regeneration) in standalone mode uses the local filesystem as the cache by default, which means each instance of your container has its own cache — fine for a single instance, incorrect for multiple. For multi-instance deployments, you need a shared cache handler, and as of writing, the OpenNext project provides the most battle-tested implementations.
For Lambda/serverless deployments outside Vercel, we use OpenNext (now Opennextjs). The project has matured considerably in the past year and covers AWS Lambda via a CloudFront distribution, Cloudflare Workers, and others. The setup is more involved than deploying to Vercel, but the cost profile is dramatically different at scale — relevant if your project has high traffic or long-tail usage patterns.
Eighteen months in, the trajectory is positive
The App Router had a rough first year. The documentation was incomplete, the caching defaults were confusing, and upgrading between minor versions felt dangerous. That has improved significantly. The documentation now covers most of the sharp edges. The Next.js 15 defaults are more predictable. React 19's stable release removed the unstable_ prefix from several APIs that the App Router depends on.
The patterns that were experimental are becoming settled. Partial Pre-Rendering — which lets a route have a static shell with dynamic streaming slots — is moving toward stability and represents what "the Next.js mental model" will look like once the framework matures.
For new projects, we default to the App Router without hesitation. For migrations from the Pages Router, we migrate incrementally: the two routers can coexist in the same application, and we migrate route by route rather than all at once. That approach has worked well on every migration we have done and lets you ship features during the migration rather than pausing for a big-bang rewrite.
The investment is worth making. The things that frustrated us early — caching, the Server Component boundary, layout persistence — are now intuitions rather than pitfalls. Your team will hit the same learning curve. Hopefully this article shortens it.