EthicsComputerCourses
Join training +91 90532 10052

Static, Dynamic Rendering & ISR

Next.js · Lesson 14 of 27 Intermediate 7 min read Updated

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 typeWhat happensExample
Fully staticThe whole page is prerendered"About us", a cached course list
Partially prerenderedA static shell is prerendered; Suspense holes stream at request timeCourse page with a "Your progress" box from a cookie
Fully dynamicAlmost everything depends on the requestA 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:

React JSXTry it yourself
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 />);
Output
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:

  1. generateStaticParams lists the pages to build ahead of time, such as your 100 best sellers.
  2. 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.
  3. In the background, Next.js renders that product with its real params, caches it, and the next visitor gets the complete page.
  4. Cached data inside follows its cacheLife, and tags let you refresh one product after an edit.
React JSX
// 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:

React JSX
// 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:

React JSX
// 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. Use next build then next start to see real static behaviour.
  • Mixing models. Do not add export const revalidate to a project using Cache Components; use cacheLife.
  • Prerendering everything. Listing 50,000 IDs in generateStaticParams makes 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. Use ensureStatic to 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_cache and segment config such as revalidate and dynamic.

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
React JSX
// 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?

2. With ISR, what does the first visitor to a product not listed in generateStaticParams get?

3. What does export const ensureStatic = "navigation" do?

4. In the previous model, which line regenerates a page at most every 10 minutes?

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