The Next.js App Router Rendering Model, Explained Properly
Static, dynamic, streamed and revalidated rendering are not four settings — they are one model with a few switches. Understanding which switch you flipped is most of Next.js performance work.
Almost every Next.js performance problem we are called in to diagnose reduces to the same sentence: nobody knew this route had become dynamic.
Not that dynamic rendering is wrong. It is that the switch from static to dynamic is implicit. It happens because someone read a cookie four components deep, and nothing in the pull request said so.
This article is the mental model we teach every team we work with.
There are not four rendering modes
There is one model — render the component tree on the server — and the only real question is when that render happens and how much of it is reused.
| When the render happens | What it is usually called |
|---|---|
| At build time, reused forever | Static |
| At build time, replaced on a signal | Incremental Static Regeneration |
| On the request, every time | Dynamic |
| At build for the shell, on request for the holes | Partial Prerendering |
Streaming is orthogonal to all of it. Streaming is about delivery order, not about when the work happens.
What actually makes a route dynamic
A route is static until something in it needs the request. Reading any of these opts the route out of static rendering:
import { cookies, headers } from 'next/headers';
import { connection } from 'next/server';
await cookies(); // needs the request
await headers(); // needs the request
await connection(); // explicitly asks for the requestsearchParams in a page's props does the same thing. So does fetch with
cache: 'no-store', and so does any unstable_noStore call left over from an
earlier refactor.
The important part is that this is contagious upward, not downward. A
Server Component deep in the tree that reads cookies() makes the whole route
dynamic, because the route cannot be prerendered if any part of it needs a
request that does not exist yet.
This is why the audit question is never "is this page static?" It is "what is the deepest thing in this tree that touches the request, and does it need to?"
The common accidental case
// app/products/[slug]/page.tsx
export default async function Page({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
return (
<>
<ProductDetail product={product} />
<RecentlyViewed /> {/* reads cookies() — the whole page is now dynamic */}
</>
);
}The product detail could have been static for a year. One personalised strip in the corner made every request re-render the lot.
The fix is not to delete the strip. It is to push it behind a Suspense boundary so the rest of the page can prerender:
<Suspense fallback={<RecentlyViewedSkeleton />}>
<RecentlyViewed />
</Suspense>With Partial Prerendering enabled, the static shell is served from the edge immediately and only the hole is computed per request. Without it, you at least get the shell streamed first instead of the whole page waiting.
Revalidation is a signal, not a timer
Most teams reach for a time-based revalidate because it is the first option in the documentation:
export const revalidate = 3600;That is a guess about how often your content changes. Tag-based revalidation is a fact about when it changed:
// Reading side
const posts = await fetch(`${API}/posts`, {
next: { tags: ['posts'] },
});
// Writing side — your CMS webhook route
import { revalidateTag } from 'next/cache';
export async function POST(request: Request) {
await verifyWebhookSignature(request);
revalidateTag('posts');
return Response.json({ revalidated: true });
}The difference in practice: with a one-hour timer, your editor publishes a correction and then refreshes for fifty minutes. With a tag, the correction is live in seconds, and the page is otherwise served from cache indefinitely. Fresher and cheaper.
Request memoisation is not the data cache
These get conflated constantly, and they solve different problems.
Request memoisation deduplicates identical fetch calls within a single
render pass. It exists so you can call getUser() in the layout and again in
the page without two round trips. It is per-render and it evaporates when the
render ends.
The data cache persists across requests and deployments. It is what
revalidate and revalidateTag operate on.
If you wrap a non-fetch data source — a database client, say — you get no
memoisation for free. Use React's cache:
import { cache } from 'react';
import 'server-only';
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});Without that wrapper, a layout and three components that each need the current user issue four identical queries per render. We have found exactly that in production more times than we would like to report.
How to audit a route in five minutes
- Run
next buildand read the route table.○is static,●is SSG with params,ƒis dynamic. Anything unexpectedlyƒis your list. - For each surprise, search the tree for
cookies,headers,searchParams,no-storeandconnection. - Ask whether that data is needed for the initial paint or only for a personalised fragment. Fragments go behind Suspense.
- Check that every non-
fetchdata function is wrapped incache. - Replace time-based
revalidatewith tags wherever a write path exists.
That list is the first hour of most performance engagements we run, and it is usually where the largest single win is hiding.
What this buys you
A route that renders statically serves its HTML from a CDN edge node in single-digit milliseconds. The same route rendered dynamically runs your component tree, your data layer and your database on every request. The difference is not a percentage. It is an order of magnitude, and it shows up in LCP, in infrastructure cost, and in what happens to your site when a link goes viral on a Tuesday afternoon.
