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.

7 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.

The order of magnitude gets worse with distance. When the origin is on another continent from the audience, an accidental dynamic render is not a slower request - it is a round trip across an ocean that a static route would not have made at all. That is most of what an audit finds in Bahrain, where in-region hosting has been available for years and a lot of applications are still served from Europe out of habit.

Related questions

How do I tell whether a route is static or dynamic?
The build output. A circle marks static and an f marks dynamic. Reading that list after a build is the fastest performance check in Next.js, and a route you expected to be static that is not is the most common finding we report.
Does reading cookies always make a route dynamic?
Yes, and everything nested under it too. That is fine on a dashboard and expensive on a marketing page that reads one cookie for a banner. Push the read down into a component that can stream, or move it to the client.
Is streaming the same as dynamic rendering?
No. Streaming decides when parts of the page arrive. Dynamic decides whether the page is built per request. A static page can stream and a dynamic one can refuse to.
When should we revalidate instead of rendering dynamically?
Whenever the data is allowed to be a few seconds or minutes old, which is most data that feels like it must be live. The difference is a whole database round trip your visitor is not waiting for.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page