Skip to content

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.

4 min read

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 happensWhat it is usually called
At build time, reused foreverStatic
At build time, replaced on a signalIncremental Static Regeneration
On the request, every timeDynamic
At build for the shell, on request for the holesPartial 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 request

searchParams 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

  1. Run next build and read the route table. is static, is SSG with params, ƒ is dynamic. Anything unexpectedly ƒ is your list.
  2. For each surprise, search the tree for cookies, headers, searchParams, no-store and connection.
  3. Ask whether that data is needed for the initial paint or only for a personalised fragment. Fragments go behind Suspense.
  4. Check that every non-fetch data function is wrapped in cache.
  5. Replace time-based revalidate with 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.

Back to all articles