Static, Dynamic Rendering & ISR
Every route in a Next.js app is rendered at some point: either ahead of time when you build the app, or when a request arrives. Knowing which one happens, and why, explains most of the speed and "why is my page stale?" questions you will meet.
In this lesson you will learn the three kinds of routes in Next.js 16, how Incremental Static Regeneration (ISR) works with Cache Components, how to guarantee a page stays static, and how the same ideas were written in the previous model that many existing apps still use.
Prerendering vs request-time rendering
- Prerendering (also called static rendering) happens during
next build, or later during revalidation. The HTML is saved and served to every visitor without running your components again. It is the fastest option and can be served from a CDN. - Request-time rendering (dynamic rendering) happens when a user's request arrives. It is needed when the output depends on that request: cookies, headers, the query string, or data that must be fresh.
With Cache Components, one route can mix both. That gives three outcomes:
| Route type | What happens | Example |
|---|---|---|
| Fully static | The whole page is prerendered | "About us", a cached course list |
| Partially prerendered | A static shell is prerendered; Suspense holes stream at request time | Course page with a "Your progress" box from a cookie |
| Fully dynamic | Almost everything depends on the request | A logged-in dashboard |
next build prints a table of your routes with a symbol for each type, and in development the Next.js dev tools show whether the current route is static or dynamic.
The rules come from the last three lessons: static markup and "use cache" results go in the shell, and request-time work (cookies, headers, search params, uncached data) must sit inside <Suspense>. The runnable example lets you toggle the parts of a page and see what kind of route it becomes:
import { useState } from "react";
import { createRoot } from "react-dom/client";
const parts = [
{ id: "header", label: "Static header", kind: "static" },
{ id: "courses", label: "Course list with \"use cache\"", kind: "cached" },
{ id: "progress", label: "Progress box that reads cookies()", kind: "dynamic" },
];
function classify(enabled, wrapped) {
const used = parts.filter((p) => enabled[p.id]);
const unwrapped = used.filter((p) => p.kind === "dynamic" && !wrapped[p.id]);
if (unwrapped.length) return "Error: " + unwrapped[0].label + " must be cached or inside <Suspense>";
if (used.some((p) => p.kind === "dynamic")) return "Partially prerendered: static shell + streamed holes";
return "Fully static: prerendered at build time";
}
function Playground() {
const [enabled, setEnabled] = useState({ header: true, courses: true, progress: false });
const [wrapped, setWrapped] = useState({ progress: true });
return (
<div>
{parts.map((p) => (
<p key={p.id}>
<label>
<input type="checkbox" checked={!!enabled[p.id]}
onChange={(e) => setEnabled({ ...enabled, [p.id]: e.target.checked })} /> {p.label}
</label>
{p.kind === "dynamic" && (
<label> <input type="checkbox" checked={!!wrapped[p.id]}
onChange={(e) => setWrapped({ ...wrapped, [p.id]: e.target.checked })} /> inside Suspense</label>
)}
</p>
))}
<strong>{classify(enabled, wrapped)}</strong>
</div>
);
}
console.log(classify({ header: true, courses: true }, {}));
console.log(classify({ header: true, courses: true, progress: true }, { progress: true }));
console.log(classify({ header: true, progress: true }, { progress: false }));
createRoot(document.getElementById("root")).render(<Playground />);Fully static: prerendered at build time Partially prerendered: static shell + streamed holes Error: Progress box that reads cookies() must be cached or inside <Suspense>
This is a simplified model, but it matches the real decision Next.js makes during the build.
Incremental Static Regeneration (ISR)
A shop with 50,000 products cannot prerender every page at build time, and you would not want to rebuild the site for each new product. Incremental Static Regeneration prerenders some pages at build time and the rest on demand, then keeps them cached and refreshes them over time.
With Cache Components it works like this:
generateStaticParamslists the pages to build ahead of time, such as your 100 best sellers.- A visitor to any other product URL is served the route's App Shell (the static shell with the product-specific parts behind their fallbacks) instantly.
- In the background, Next.js renders that product with its real params, caches it, and the next visitor gets the complete page.
- Cached data inside follows its
cacheLife, and tags let you refresh one product after an edit.
// app/products/[id]/page.jsx
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";
export async function generateStaticParams() {
const top = await getBestSellerIds(); // e.g. 100 ids
return top.map((id) => ({ id: String(id) }));
}
export default function ProductPage({ params }) {
return (
<Suspense fallback={<p>Loading product...</p>}>
{params.then(({ id }) => <Product id={id} />)}
</Suspense>
);
}
async function Product({ id }) {
"use cache";
cacheLife("days");
cacheTag(`product-${id}`);
const p = await getProduct(id);
return <h1>{p.name}: ₹{p.price}</h1>;
}The page reads params inside the boundary (here with params.then(...), which works because params is a Promise), so the shell can be built without knowing the product.
Guaranteeing a static route with ensureStatic
On a marketing site you may want a promise that a page stays static, so nobody accidentally adds a cookie read that makes every visit run on the server. Next.js 16.4 added the ensureStatic route segment option:
// app/(marketing)/layout.jsx
export const ensureStatic = "navigation";
export default function MarketingLayout({ children }) {
return <>{children}</>;
}With "navigation", the build fails if any page under this layout contains dynamic content. The less strict values "prefetch" and "shell" guarantee only that the prefetched data or the first shell is static.
Here is the same ISR page in the previous model, for comparison:
// app/products/[id]/page.jsx (previous model, cacheComponents off)
export const revalidate = 86400; // seconds: regenerate at most once a day
export const dynamicParams = true; // render unknown ids on demand (the default)
export async function generateStaticParams() {
const top = await getBestSellerIds();
return top.map((id) => ({ id: String(id) }));
}
export default async function ProductPage({ params }) {
const { id } = await params;
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 86400, tags: [`product-${id}`] },
});
const p = await res.json();
return <h1>{p.name}: ₹{p.price}</h1>;
}Note that revalidate had to be a plain number such as 86400, not an expression like 60 * 60 * 24, because Next.js reads it without running your code.
Common mistakes
- Testing speed in
next dev. Development renders every request fresh. Usenext buildthennext startto see real static behaviour. - Mixing models. Do not add
export const revalidateto a project using Cache Components; usecacheLife. - Prerendering everything. Listing 50,000 IDs in
generateStaticParamsmakes builds slow. Prerender the popular pages and let ISR handle the rest. - Accidentally dynamic marketing pages. One
cookies()call in a shared layout can make many pages request-time. UseensureStaticto catch it at build time.
Key points
- Routes are fully static, partially prerendered (static shell plus streamed holes) or fully dynamic.
- Static markup and cached results form the shell; request-time data must be inside
<Suspense>. - ISR prerenders some params at build time and renders the rest on first visit, then caches them.
export const ensureStatic = "navigation"fails the build if dynamic content sneaks into a route.- The previous model used fetch options,
unstable_cacheand segment config such asrevalidateanddynamic.
Exercise
A blog has 2,000 posts. Write app/blog/[slug]/page.jsx for an app using Cache Components that prerenders only the 50 most-read posts at build time, caches each post for a day with a tag post-SLUG, and serves any other post through ISR. Then write one line explaining how you would do the same in the previous model.
Show solution
// app/blog/[slug]/page.jsx
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";
import { getMostReadSlugs, getPost } from "@/lib/blog";
export async function generateStaticParams() {
const slugs = await getMostReadSlugs(50);
return slugs.map((slug) => ({ slug }));
}
export default function PostPage({ params }) {
return (
<Suspense fallback={<p>Loading post...</p>}>
{params.then(({ slug }) => <Post slug={slug} />)}
</Suspense>
);
}
async function Post({ slug }) {
"use cache";
cacheLife("days");
cacheTag(`post-${slug}`);
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}Previous model: keep generateStaticParams, add export const revalidate = 86400 and fetch the post with { next: { tags: [post-${slug}] } }.
Quick Quiz
1. What is in a partially prerendered route's static shell?
The shell contains everything known at build time; request-time parts stream into the fallbacks.
2. With ISR, what does the first visitor to a product not listed in generateStaticParams get?
Unknown params are served the shell first, then upgraded and cached for later visitors.
3. What does export const ensureStatic = "navigation" do?
It guarantees that navigations to the route never need request-time rendering.
4. In the previous model, which line regenerates a page at most every 10 minutes?
Segment config values must be plain literals; cacheLife belongs to Cache Components.
Learn Next.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
