Next.js App Router in production: components, caching, hydration.
Keeping client bundles small, avoiding request waterfalls, caching explicitly with Cache Components and fixing hydration mismatches in Next.js 16.
The Next.js App Router (the app/ directory) changed how React apps are built. React Server Components, streaming, nested layouts and server-side data fetching make it possible to send much less JavaScript to the browser. They also introduce new ways to get things wrong: unexpected caching, hydration errors, request waterfalls and client bundles that grow quietly.
Most of these problems come from treating Server Components like traditional client-side React components in a new folder. This guide covers the patterns that matter most in production, written against Next.js 16, the current major version at the time of writing. Caching in particular has changed across recent versions, so always check the official documentation for the version you run.
Push 'use client' down to the leaves
In the App Router, components are Server Components by default. They run on the server, and their own code and dependencies aren't sent to the browser; the browser receives HTML and a compact description of the rendered tree (the RSC payload).
'use client' marks a boundary. Every module the marked file imports becomes part of the client bundle too. Put it at the top of a layout or a large page component, and you can end up shipping a big chunk of your app (including libraries that were only needed for rendering) to every visitor.
The "children" pattern
To keep most of the page on the server while still adding interactivity:
-
1
Keep the page as a Server Component that fetches its own data with
async/await. -
2
When an interactive wrapper (a modal, a collapsible sidebar, a tab set) needs to contain server-rendered content, pass that content to it as
childreninstead of importing it inside the client file. -
3
Mark only the genuinely interactive pieces (a button, a search box, a toggle) with
'use client'.
// app/products/page.tsx (Server Component)
import { Drawer } from './drawer' // 'use client' inside
import { ProductSpecs } from './product-specs' // stays on the server
export default async function Page() {
const product = await getProduct()
return (
<Drawer>
<ProductSpecs product={product} />
</Drawer>
)
}
Less JavaScript means less work on the main thread, which helps responsiveness metrics such as Interaction to Next Paint. Our explainer on Core Web Vitals covers what Google measures and the "good" thresholds.
Avoid request waterfalls
A waterfall happens when one request waits for another it doesn't depend on. On the server it's easy to write by accident: await getUser(), then await getOrders(), then await getRecommendations(), each waiting for the last.
-
Start independent requests together with
Promise.all. -
Move slow, non-essential sections into their own components wrapped in
<Suspense>, so the rest of the page streams first and those sections fill in when ready. -
Don't worry about calling the same
fetchin a layout and a page: identicalGETrequests are memoised within a single render pass, so it only runs once. For database or ORM calls, wrap the function in React'scacheto get the same behaviour.
Caching: be explicit
Caching is where most App Router confusion comes from, partly because the defaults have changed between versions. Since Next.js 15, fetch requests are not cached by default. Next.js 16 adds Cache Components, enabled with cacheComponents: true in next.config.ts and on by default in new apps created with create-next-app. The performance work we do on Next.js sites usually starts with understanding which model an app is on.
With Cache Components
You opt in to caching with the 'use cache' directive, on a data function or on a whole component, and give it a lifetime and tags:
import { cacheLife, cacheTag } from 'next/cache'
export async function getProducts() {
'use cache'
cacheLife('hours')
cacheTag('products')
return db.query('SELECT * FROM products')
}
-
cacheLifesets how long the result stays fresh, using built-in profiles (such as'minutes','hours','days') or a custom object. Next.js recommends pairing every cache directive with one. -
cacheTaglabels cached data so you can refresh it on demand. -
revalidateTag('products', 'max'), called from a Server Action or Route Handler, marks tagged data as stale; visitors get the cached version while a fresh one is generated in the background. It suits content such as a CMS webhook firing after an editor publishes. -
updateTag('products'), available only in Server Actions, expires the data immediately, so the user who just made a change sees it on the next request.
Data that must be fresh on every request should not use 'use cache'; wrap the component that reads it in <Suspense> so the rest of the page can be prerendered and the fresh part streams in.
Without Cache Components (the previous model)
Apps that haven't enabled Cache Components still use per-request options: fetch(url, { cache: 'force-cache' }) to cache, { next: { revalidate: 3600 } } for time-based refreshes, and { next: { tags: ['products'] } } with revalidateTag for on-demand refreshes. Next.js publishes a migration guide for moving to Cache Components; plan it as a deliberate piece of work rather than a find-and-replace.
If your content comes from a headless CMS, tag-based revalidation from a publish webhook is usually the cleanest setup. For choosing between static, server-rendered and incrementally regenerated pages in the first place, see our SSG vs SSR vs ISR comparison.
Avoid hydration mismatches
A hydration error means the HTML rendered on the server didn't match what React rendered in the browser on its first pass. Common causes:
-
Browser-only values in the first render. Reading
window.innerWidth,localStorageornavigatorwhile rendering gives different results on the server. Read them inuseEffect(or viauseSyncExternalStore) and render a neutral default first. -
Dates and times.
new Date().toLocaleDateString()can differ between the server's timezone and locale and the user's. Format with an explicit timezone and locale, or render the local version on the client. -
Random values.
Math.random()or generated IDs differ between server and client; use React'suseIdfor IDs. -
Invalid HTML nesting. A
<div>inside a<p>, or an<a>inside another<a>, gets corrected by the browser's parser, so the DOM no longer matches what React expects. -
Browser extensions and early scripts. Extensions that inject attributes, or a theme script that sets a class on
<html>before hydration, can also trigger warnings. For the theme case,suppressHydrationWarningon the<html>element is the accepted fix.
Images and fonts
Use next/image for content images. It serves resized versions to suit each device, lazy-loads images below the fold, and can convert to WebP by default (AVIF can be enabled through the images.formats setting). Always give images dimensions, or use fill within a sized container, to avoid layout shift. Our article on WebP and AVIF covers the trade-offs. Pair this with next/font, which self-hosts fonts and generates size-adjusted fallbacks to minimise layout shift when they load.
A production checklist
-
'use client'appears only in small, interactive components, not in layouts or pages. -
Independent data requests run in parallel; slow sections sit behind
<Suspense>. - Every cached function has a lifetime and tags, and every content change has a matching revalidation path.
- The build output and bundle analyser have been checked for unexpectedly large client chunks.
- The console shows no hydration warnings in production builds.
-
Images have dimensions and fonts use
next/font.
We build and audit App Router projects as part of our Next.js development work. If your app has caching or performance issues you'd like looked at, get in touch.
Planning something similar?
Tell us what you're building and we'll come back with ideas and next steps.