Architecture
17 Sept 2026•8 min read•By Hypynnax

Static, ISR or Dynamic? A Practical Guide to Rendering and Caching in the Next.js App Router

Stop guessing why a page is stale or slow. A clear mental model of static rendering, ISR, dynamic rendering and on-demand revalidation in Next.js — with the real-world gotchas of pairing it with a database.

#Next.js#Caching#ISR#Performance#App Router
Static, ISR or Dynamic? A Practical Guide to Rendering and Caching in the Next.js App Router
NOTE

Caching behaviour changed noticeably between Next.js 14, 15 and 16. The concepts below are stable, but always confirm exact defaults in the documentation for the version you run.

The question behind every slow or stale page

Sooner or later every Next.js developer hits one of two symptoms:

  1. "I updated the content but the site still shows the old version."
  2. "Every request hits my database and the site feels slow."

Both have the same root cause: not knowing when a page is rendered and where its result is stored. The App Router gives you a spectrum of options between "rendered once at build time" and "rendered on every request", and the right choice is different for a marketing page, a blog, a dashboard and a checkout.

This article builds a mental model you can apply to any route, then walks through the traps that catch people in production. The examples come from a real project: a portfolio whose content lives in MongoDB and is edited from an admin panel.

Three moments a page can be rendered

Forget framework jargon for a second. There are only three moments at which HTML can be produced:

MomentNameCost per visitorFreshness
At build timeStatic rendering~0 (served from CDN)Frozen until the next deploy
At build time, then refreshed in the backgroundISR (Incremental Static Regeneration)~0 for most visitorsStale by at most N seconds
On every requestDynamic renderingA full render each timeAlways fresh

Everything else — revalidatePath, unstable_cache, streaming — is a tool for moving a route along that line.

What decides which mode a route gets?

Next.js infers the mode from what your code does. A route is static by default, and becomes dynamic when it touches something that can only be known at request time:

  • reading cookies() or headers()
  • reading searchParams in a page
  • using connection() or explicitly exporting export const dynamic = "force-dynamic"
  • fetching with an explicit cache: "no-store"

The build output tells you exactly what happened. Learn to read those little symbols:

text
Route (app)                     Revalidate
┌ ○ /                                  1m
├ ● /blog/[slug]                       1m
├ ƒ /admin
└ ƒ /api/contact

○  (Static)   prerendered as static content
●  (SSG)      prerendered using generateStaticParams
ƒ  (Dynamic)  server-rendered on demand

If a page you expected to be ○ shows up as ƒ, something in its tree — often a layout — is reading cookies() or headers(). One dynamic API call in a layout makes every page beneath it dynamic. That is the number one accidental performance regression I see.

ISR in one paragraph

ISR is stale-while-revalidate for pages. You tell Next.js how long a rendered page may be considered fresh:

typescript
// app/blog/page.tsx
export const revalidate = 60; // seconds

export default async function BlogPage() {
  const posts = await getPosts(); // any data source — database, CMS, API
  return <PostList posts={posts} />;
}

The first visitor after the interval still receives the cached page instantly. In the background Next.js re-renders it, and once that succeeds, later visitors get the new version. Nobody ever waits for a render, which is why ISR is the sweet spot for content sites.

Two subtleties trip people up:

  • The revalidation is lazy. Nothing happens until someone requests the page after the interval has elapsed. A page nobody visits is never regenerated.
  • A failed regeneration keeps serving the old page. That is a feature — your site survives a database outage — but it also means errors can hide. Log them.

Pairing ISR with a database (not fetch)

Most documentation shows fetch examples. Real applications often talk to a database driver directly, and there the fetch caching options simply do not apply. The route-level revalidate export is what you want, and it works with any data source.

Here is the pattern I use for a portfolio backed by MongoDB:

typescript
// lib/data.ts
export async function getProjects() {
  await connectDB();
  return ProjectModel.find({ published: true })
    .sort({ order: 1 })
    .lean();
}

// app/projects/page.tsx
export const revalidate = 60;

Now visitors are served from cache and the database sees roughly one query per minute per page, no matter how much traffic arrives.

Dynamic segments and generateStaticParams

For a route like /blog/[slug] you have two options. You can pre-render every slug at build time, or generate pages on first request and cache them. The second option has a big advantage when the database is not reachable during the build (for example on a CI machine):

typescript
// app/blog/[slug]/page.tsx
export const revalidate = 60;

// Render nothing at build time; generate each page on first visit, then cache it.
export async function generateStaticParams() {
  return [];
}

The result is marked ● in the build output: static-like behaviour, built lazily.

On-demand revalidation: make edits appear instantly

Waiting up to a minute after publishing a post is fine for readers but frustrating for the author staring at the site. On-demand revalidation lets a write operation purge the cache immediately:

typescript
import { revalidatePath } from "next/cache";

export async function PUT(req: Request, { params }: Ctx) {
  // ...validate, authorise and save to the database
  revalidatePath("/", "layout"); // purge every page under the root layout
  return Response.json({ ok: true });
}

The second argument matters. revalidatePath("/blog") purges that one URL; revalidatePath("/", "layout") purges everything that shares the root layout. For a small site the blunt version is perfectly fine and removes a whole class of "I forgot to purge the tag" bugs.

For larger applications, tags give you surgical control: label cached data with tags: ["posts"], then call revalidateTag("posts") when a post changes. Only the pages that depend on posts are refreshed.

Caching the data instead of the page

Sometimes the page must be dynamic (it depends on the logged-in user) but a part of its data is expensive and shared — a leaderboard, a pricing table, a list of categories. Cache the data function, not the page.

Historically this was unstable_cache. Newer Next.js versions add a "use cache" directive as part of the Cache Components model, which you opt into in configuration. The idea is identical in both: memoise the result of an async function for some time, keyed by its arguments, so that dynamic pages can still reuse expensive shared work. Check your version's docs for the exact API; the design question — what can be shared between users, and for how long? — is the same everywhere.

Streaming: make the slow part not block the fast part

Not everything can be cached. When a page has a fast shell and one slow, personalised widget, do not make the whole page wait. Wrap the slow part in <Suspense> and let the shell stream immediately:

tsx
import { Suspense } from "react";

export default function Dashboard() {
  return (
    <>
      <Header />
      <Suspense fallback={<StatsSkeleton />}>
        <SlowStats />   {/* async server component */}
      </Suspense>
    </>
  );
}

The user sees a usable page in a few hundred milliseconds instead of staring at a blank tab for two seconds. A loading.tsx file next to a route does the same for the route as a whole.

A decision table you can actually use

PageRecommended strategyWhy
Landing / marketingStatic or ISR (revalidate in minutes to hours)Identical for everyone
Blog postISR + on-demand revalidation on publishRead-heavy, edits are rare
Blog / product listingISRSame as above, small payload
Search resultsDynamic (or client-side fetch)Depends on the query
DashboardDynamic + streamingPersonalised per user
Checkout / accountDynamic, never cachedSensitive and user-specific
Admin panelDynamic (force-dynamic), noindexMust never show stale data

Pitfalls I have personally stepped on

1. Personalised data inside a cached page. If a cached page renders the current user's name, the first visitor's name can be served to everyone. Anything user-specific belongs in a dynamic route or a client component that fetches after load.

2. Build-time database access. If next build runs where the database is unreachable, static pages that query it will fail or render empty. Fail soft — return empty data with a short revalidate — and prefer generateStaticParams() { return [] } for dynamic segments.

3. Environment variables baked in at build. Only NEXT_PUBLIC_* variables are inlined into client bundles, and they are inlined when you build, not when you run. Changing one requires a new build.

4. The client-side Router Cache. The browser also caches visited routes for instant back/forward navigation. Recent Next.js versions made page segments uncached on the client by default, but if you see stale data only in the browser and not in curl, this is the layer to inspect. router.refresh() clears it.

5. Forgetting that ISR needs a running server or platform support. On a static export (output: "export") there is no server to regenerate anything; features like ISR and revalidatePath require a Node server or a platform such as Vercel that provides the equivalent.

How to debug "why is this stale?"

I go through the layers in a fixed order, from closest to the user outward:

  1. Browser — hard refresh, or curl the URL to bypass the client Router Cache.
  2. CDN — inspect the response headers (x-vercel-cache: HIT | STALE | MISS, or cache-control).
  3. Full Route Cache — is the route ○/● when you expected ƒ, or the reverse? Check the build output.
  4. Data layer — is there an unstable_cache, a tag, or a revalidate on the data function?
  5. The source of truth — did the write actually reach the database?

Ninety percent of the time the answer is found by the second step.

Summary

  • Pages are rendered at build, in the background (ISR) or per request. Pick the cheapest moment that is still fresh enough.
  • Reading cookies(), headers() or searchParams makes a route dynamic — and a layout that does so drags its whole subtree along.
  • For database-backed content, export const revalidate = N plus revalidatePath after writes gives near-static speed with near-instant edits.
  • Cache data when pages must be dynamic; stream when part of a page is slow.
  • Read the build output. It is the single most honest description of what your app really does.