|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/static-generation
$cat docs/isr,-ssg-&-static-generation-in-next.js.md
updated Last weekยท30 min readยทpublished

ISR, SSG & Static Generation in Next.js

โ—†Next.jsโ—†Staticโ—†Performanceโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Introduction

Next.js does not force you into a single rendering strategy. The App Router lets you choose, on a per-route basis, whether a page is rendered at request time, at build time, or somewhere in between. The three primary strategies are Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). Understanding when each one fits is the difference between a site that feels instant and one that crumbles under load.

Static Site Generation renders pages at build time. The resulting HTML can be served from a CDN edge with no compute at request time. This gives you the fastest possible response and the highest cacheability, but it only works when the data is known ahead of time and does not change frequently.

Server-Side Rendering renders pages on each request. It is the right choice when the response depends on cookies, headers, or data that changes too frequently to cache. The trade-off is latency and server load: every request runs your code, and the response cannot be cached by default.

Incremental Static Regeneration sits between the two. Pages are rendered at build time, but Next.js can regenerate them in the background after a configured interval. Visitors get the cached version immediately while a fresh version is prepared behind the scenes. ISR is the workhorse of content-heavy sites: product pages, blog posts, documentation, and marketing landers.

This guide covers the mechanics of static generation in Next.js 16, the power of generateStaticParams, time-based and on-demand revalidation, draft mode for previewing CMS content, and the often misunderstood interaction between static rendering and Next.js caches. The goal is a mental model that helps you ship fast pages that stay fresh without over-engineering.

Static Site Generation (SSG)

In the Next.js App Router, a route is statically generated by default if it does not read dynamic data that depends on the request. Static rendering means the page is built once, the HTML is written to disk, and subsequent requests are served from the Full Route Cache without invoking your component code again. This is the most efficient path through the framework.

For a static route to be generated at build time, Next.js must be able to resolve all the dynamic segments of the URL. If the route is a dynamic segment like app/posts/[slug]/page.tsx, Next.js needs to know which values of slug to render. That is the job of generateStaticParams. Without it, the route will be rendered dynamically on the first request, a behavior known as dynamic rendering.

page.tsx
TSX
1// app/posts/[slug]/page.tsx
2export default async function PostPage({
3 params,
4}: {
5 params: Promise<{ slug: string }>;
6}) {
7 const { slug } = await params;
8 const post = await getPost(slug);
9
10 if (!post) {
11 return <div>Post not found</div>;
12 }
13
14 return (
15 <main>
16 <h1>{post.title}</h1>
17 <article>{post.content}</article>
18 </main>
19 );
20}
21
22export async function generateStaticParams() {
23 const posts = await getPosts();
24
25 return posts.map((post) => ({
26 slug: post.slug,
27 }));
28}

Notice that the component still reads data with getPost(slug). Because generateStaticParams enumerated every slug, Next.js runs the data fetch during the build and embeds the result in the static HTML. The same getPost function is used at build time and at runtime; the only difference is when it runs.

Next.js marks statically generated routes with a small static rendering indicator during development. You can also see the result in the build output. Routes that are prerendered are listed with a lambda symbol, while dynamic routes are marked differently. The static rendering indicator is useful for catching accidental dynamic rendering caused by a stray cookies() or headers() call.

๐Ÿ“

note

Static rendering is the default. If a Server Component does not call dynamic APIs such as cookies(), headers(), or uncached data fetches, Next.js will build it as a static page. Keep dynamic logic out of shared layouts unless you want to opt routes out of static rendering.
generateStaticParams in Depth

generateStaticParams is the bridge between dynamic route segments and static generation. It returns an array of objects, each containing the values for the dynamic segments of the route. For a route like app/shop/[category]/[product]/page.tsx, each object must provide both category and product.

page.tsx
TSX
1// app/shop/[category]/[product]/page.tsx
2export async function generateStaticParams() {
3 const products = await getProducts();
4
5 return products.map((product) => ({
6 category: product.categorySlug,
7 product: product.slug,
8 }));
9}

The function is called during the build for every static segment it covers. It can fetch data from a CMS, database, or file system. It can also be static itself if the segment list is known at compile time. Keep the function focused on returning segment values; do not perform side effects or mutate state.

Returning an empty array from generateStaticParams has a specific meaning: it tells Next.js that there are no static paths to pre-render, but the route segment is still valid. Combined with the correct dynamicParams setting, this can let you control whether unknown dynamic segments should return a 404 or be rendered on demand.

empty-params.tsx
TSX
1// Generate no static variants at build time, but allow dynamic rendering
2export const dynamicParams = true;
3
4export async function generateStaticParams() {
5 return [];
6}
7
8export default async function Page({
9 params,
10}: {
11 params: Promise<{ id: string }>;
12}) {
13 const { id } = await params;
14 const data = await fetchResource(id);
15 return <div>{data.name}</div>;
16}

The dynamicParams export controls fallback behavior for dynamic segments not returned by generateStaticParams. When set to true, unknown segments are rendered on demand, similar to the old Pages Router fallback true or blocking. When set to false, unknown segments return a 404, similar to fallback false.

dynamicParamsBehavior for unknown segmentsPages Router equivalent
trueRender on demand and cache the resultfallback: true or blocking
falseReturn 404 for unknown segmentsfallback: false

A common error is returning an invalid shape from generateStaticParams. Each array element must be a plain object whose keys match the dynamic segment names. Forgetting a nested segment, using the wrong key name, or returning a string instead of an object will throw at build time. Next.js also validates that the values are strings, because URL segments are always strings.

valid-params.tsx
TSX
1// Valid
2export async function generateStaticParams() {
3 return [{ slug: "hello-world" }, { slug: "second-post" }];
4}
5
6// Invalid: missing segment key
7export async function generateStaticParams() {
8 return [{ id: "hello-world" }]; // slug missing
9}
10
11// Invalid: value must be a string
12export async function generateStaticParams() {
13 return [{ slug: 123 }];
14}

When you have a catch-all or optional catch-all segment, the key is an array. For app/docs/[...slug]/page.tsx, each object should have a slug array. For app/[[...slug]]/page.tsx, the array can be empty to represent the root path.

catch-all-params.tsx
TSX
1// app/docs/[...slug]/page.tsx
2export async function generateStaticParams() {
3 return [
4 { slug: ["getting-started"] },
5 { slug: ["deployment", "docker"] },
6 { slug: ["deployment", "vercel"] },
7 ];
8}
9
10// app/[[...slug]]/page.tsx
11export async function generateStaticParams() {
12 return [
13 { slug: [] },
14 { slug: ["about"] },
15 { slug: ["products", "features"] },
16 ];
17}
โš 

warning

Large generateStaticParams arrays can explode build times. If you have tens of thousands of paths, consider rendering only the most popular ones statically and letting the rest render on demand with dynamicParams = true.
Incremental Static Regeneration (ISR)

ISR gives static pages a freshness dial. You configure a revalidation interval, and Next.js uses stale-while-revalidate semantics to serve cached HTML while regenerating it in the background. The first visitor after the interval expires still gets the stale version instantly, and Next.js triggers a regeneration for the next visitor.

To enable ISR, export revalidate from a route or page. The value is a number of seconds. A value of 60 means the page can be cached for up to one minute before Next.js considers it stale. When the revalidation window is reached, the next request for the page will serve the stale copy and trigger a background refresh.

isr-page.tsx
TSX
1// app/blog/[slug]/page.tsx
2export const revalidate = 60;
3
4export async function generateStaticParams() {
5 const posts = await getPosts();
6 return posts.map((post) => ({ slug: post.slug }));
7}
8
9export default async function PostPage({
10 params,
11}: {
12 params: Promise<{ slug: string }>;
13}) {
14 const { slug } = await params;
15 const post = await fetch(`https://api.example.com/posts/${slug}`, {
16 next: { revalidate: 60 },
17 }).then((res) => res.json());
18
19 return (
20 <main>
21 <h1>{post.title}</h1>
22 <p>{post.content}</p>
23 </main>
24 );
25}

In the example above, both the route-level revalidate export and the per-fetch next.revalidate option are set to 60 seconds. The route-level value controls the Full Route Cache, while the fetch-level value controls the Data Cache. For ISR to work consistently, keep these values in sync or understand the interaction between them.

Setting revalidate = 0 opts the route out of static generation and the Full Route Cache entirely. It effectively forces dynamic rendering on every request. Setting revalidate = false caches the page forever with no revalidation. This is useful for content that never changes, such as a changelog or a fixed legal page.

revalidate valueBehaviorUse case
Number > 0ISR with stale-while-revalidateBlogs, product pages, marketing content
falseCache foreverStatic reference content, legal pages
0Dynamic rendering on every requestUser dashboards, real-time data

ISR is not real-time. A visitor can see stale content for up to twice the revalidation interval in the worst case: once for the initial stale window, and once for the next visitor after the regeneration finishes. If you need true real-time behavior, use SSR or on-demand revalidation rather than a fixed time interval.

โ„น

info

Choose a revalidation interval based on how stale is too stale. A news homepage might use 60 seconds; a blog post might use 3600 seconds; an API documentation page might use 86400 seconds. The longer the interval, the less server load and the higher the cache hit rate.
On-Demand Revalidation

Time-based revalidation is simple but imprecise. On-demand revalidation lets you invalidate the cache exactly when something changes, such as when a CMS publishes a new article or a user updates their profile. Next.js provides two functions: revalidatePath and revalidateTag.

revalidatePath invalidates everything associated with a specific path. It is the easiest to use because it matches the URL structure you already understand. However, it is also coarse-grained: if multiple data sources feed the same page, revalidatePath will invalidate all of them.

revalidate-path.ts
TSX
1"use server";
2
3import { revalidatePath } from "next/cache";
4
5export async function publishPost(postId: string) {
6 // Update your database
7 await db.post.update({
8 where: { id: postId },
9 data: { published: true },
10 });
11
12 // Invalidate the specific page
13 revalidatePath(`/blog/${postId}`);
14
15 // Also invalidate listing pages that include this post
16 revalidatePath("/blog");
17 revalidatePath("/");
18}

revalidateTag is more precise. You tag data fetches with arbitrary string tags, and then invalidate only those tags. This is ideal when multiple pages share the same underlying data, such as a product that appears in a category page, a search page, and a related-products widget.

revalidate-tag.tsx
TSX
1// app/products/[id]/page.tsx
2export default async function ProductPage({
3 params,
4}: {
5 params: Promise<{ id: string }>;
6}) {
7 const { id } = await params;
8
9 const product = await fetch(`https://api.example.com/products/${id}`, {
10 next: { tags: [`product-${id}`, "products"] },
11 }).then((res) => res.json());
12
13 return (
14 <main>
15 <h1>{product.name}</h1>
16 <p>{product.description}</p>
17 </main>
18 );
19}
20
21// Server Action or route handler triggered by a CMS webhook
22"use server";
23
24import { revalidateTag } from "next/cache";
25
26export async function syncProduct(productId: string) {
27 await revalidateTag(`product-${productId}`);
28 await revalidateTag("products");
29}

On-demand revalidation is typically triggered from a Server Action, a route handler, or a webhook. A CMS webhook is the most common pattern: when a content editor publishes a page, the CMS sends a POST request to your app, and the route handler invalidates the affected cache entries.

route.ts
TSX
1// app/api/revalidate/route.ts
2import { revalidatePath, revalidateTag } from "next/cache";
3import { NextRequest, NextResponse } from "next/server";
4
5export async function POST(request: NextRequest) {
6 const secret = request.headers.get("x-revalidate-secret");
7
8 if (secret !== process.env.REVALIDATE_SECRET) {
9 return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
10 }
11
12 const body = await request.json();
13 const { type, slug, tags } = body;
14
15 try {
16 if (type === "path" && slug) {
17 revalidatePath(slug);
18 }
19
20 if (type === "tag" && Array.isArray(tags)) {
21 tags.forEach((tag) => revalidateTag(tag));
22 }
23
24 return NextResponse.json({ revalidated: true, now: Date.now() });
25 } catch (error) {
26 return NextResponse.json({ message: "Revalidation failed" }, { status: 500 });
27 }
28}

Always protect your revalidation endpoint. If an attacker can call it, they can force your origin to do unnecessary work and potentially trigger cache invalidation patterns. Use a shared secret, IP allowlisting, or a signed request from your CMS. The secret should live in an environment variable and never be exposed to the client.

โœ“

best practice

Prefer revalidateTag over revalidatePath for data that is shared across pages. Tags create a clean dependency graph between your data fetches and your invalidation events, making large sites easier to reason about.
Draft Mode

Draft mode lets you preview unpublished content on a production-like URL. When a CMS editor clicks a preview link, your app enables draft mode, bypasses the static cache, and renders the page with the latest draft data. Draft mode is implemented with a cookie and the draftMode() helper.

To enable draft mode, create a route handler that calls (await draftMode()).enable(). This sets a secure, HttpOnly cookie on the response. Subsequent requests with that cookie will opt out of the static cache and the Data Cache for draft fetches, letting your Server Components read live data.

draft-route.ts
TSX
1// app/api/draft/route.ts
2import { draftMode } from "next/headers";
3import { redirect } from "next/navigation";
4import { NextRequest } from "next/server";
5
6export async function GET(request: NextRequest) {
7 const { searchParams } = request.nextUrl;
8 const secret = searchParams.get("secret");
9 const slug = searchParams.get("slug");
10
11 if (secret !== process.env.DRAFT_SECRET || !slug) {
12 return new Response("Invalid token", { status: 401 });
13 }
14
15 const draft = await draftMode();
16 draft.enable();
17
18 redirect(`/blog/${slug}`);
19}

In your page, check (await draftMode()).isEnabled to decide whether to fetch the draft or the published version. When draft mode is active, data fetches should be uncached so the editor sees the latest changes. When it is inactive, you can use the static or ISR path.

draft-page.tsx
TSX
1// app/blog/[slug]/page.tsx
2import { draftMode } from "next/headers";
3
4export default async function PostPage({
5 params,
6}: {
7 params: Promise<{ slug: string }>;
8}) {
9 const { slug } = await params;
10 const { isEnabled } = await draftMode();
11
12 const post = await fetch(
13 `https://api.example.com/posts/${slug}${isEnabled ? "?draft=true" : ""}`,
14 {
15 cache: isEnabled ? "no-store" : "force-cache",
16 next: isEnabled ? undefined : { tags: [`post-${slug}`] },
17 }
18 ).then((res) => res.json());
19
20 return (
21 <main>
22 <h1>{post.title}</h1>
23 <article>{post.content}</article>
24 {isEnabled && (
25 <div className="draft-banner">Preview mode is enabled</div>
26 )}
27 </main>
28 );
29}

To disable draft mode, create another route handler that calls (await draftMode()).disable(). This clears the cookie and returns the user to the normal, cached version of the site. CMS preview links usually include an expiry or a manual exit button that links to this handler.

disable-draft-route.ts
TSX
1// app/api/disable-draft/route.ts
2import { draftMode } from "next/headers";
3import { redirect } from "next/navigation";
4
5export async function GET() {
6 const draft = await draftMode();
7 draft.disable();
8 redirect("/");
9}

Draft mode is intentionally a single-session escape hatch. The cookie is scoped to the browser and can be cleared by the user. Do not use draft mode to gate access to content that should be authenticated; use proper auth for that. Draft mode is only about previewing content before it is published.

โš 

warning

The draft-mode cookie is HttpOnly and set by the server. Protect the enable endpoint with a secret token so that only authorized users, such as CMS editors, can enter preview mode.
SSG vs SSR Decision Matrix

Choosing a rendering strategy is a trade-off between latency, freshness, build complexity, and operational cost. The following matrix compares the three main strategies across dimensions that matter in production. Use it as a starting point, not a rulebook; the best choice often depends on the specific access pattern and data source.

DimensionSSGISRSSR
LatencyLowest, served from edge cacheLow, stale response served instantlyHigher, rendered per request
FreshnessStale until next buildBounded staleness by revalidation intervalAlways fresh at request time
Build timeHigh, every page pre-renderedMedium, popular paths pre-renderedLow, nothing rendered at build
ScalabilityHighest, static files scale horizontallyHigh, most traffic hits cacheLimited by origin compute
PersonalizationNone by defaultNone by defaultFull, based on cookies/headers
Best forMarketing pages, docs, legalBlogs, product catalogs, content sitesDashboards, admin, real-time apps

A common hybrid approach is to use SSG for the shell of a user dashboard and then hydrate client-side data for personalized widgets. This is sometimes called static shell with client-side data fetching. It gives you the fast initial paint of SSG while keeping the dynamic parts dynamic. Be careful with SEO: content that matters for search engines should be server-rendered.

For e-commerce, ISR is usually the sweet spot. Product pages are pre-rendered at build time for the catalog, then revalidated when inventory or pricing changes. The cart and checkout are dynamic routes that read cookies and session state. Splitting the app by route lets each part use the right strategy.

๐Ÿ“

note

You can mix strategies within the same app. A product listing page can be ISR, a user dashboard can be SSR, and an about page can be SSG. Next.js decides per route based on the data-fetching and dynamic APIs used in the component tree.
Caching Interaction

Next.js 16 has several layers of caching, and static generation interacts with all of them. Understanding the difference between the Data Cache, the Full Route Cache, and the Router Cache is essential for debugging stale content and unexpected revalidation behavior.

The Data Cache stores the results of fetch requests. By default, fetch is cached in Next.js. You can control the cache with cache: "force-cache", cache: "no-store", or the next.revalidate and next.tags options. When static generation runs at build time, it writes fetch results into the Data Cache.

The Full Route Cache stores the rendered HTML and RSC payload for a route. Static pages are stored here by default. ISR pages are also stored here, but with a revalidation timestamp. When a route is revalidated, Next.js clears the Full Route Cache entry and regenerates the page. The Data Cache may or may not be invalidated depending on how you configured the data fetch.

The Router Cache is a client-side cache maintained by the Next.js router. It keeps rendered RSC payloads in the browser so that navigation between previously visited routes feels instant. The Router Cache is invalidated on client-side navigation, revalidation, and session changes. It can be the source of confusion when you revalidate on the server but the client still shows the old page until a hard refresh or navigation.

cache-coordination.tsx
TSX
1// Coordinate Data Cache and Full Route Cache
2export const revalidate = 60; // Full Route Cache invalidation interval
3
4export default async function Page() {
5 const data = await fetch("https://api.example.com/data", {
6 next: {
7 revalidate: 60, // Data Cache invalidation interval
8 tags: ["homepage-data"],
9 },
10 }).then((res) => res.json());
11
12 return <div>{data.value}</div>;
13}
14
15// When you revalidate the tag, both caches are cleared for the next request
16"use server";
17import { revalidateTag } from "next/cache";
18
19export async function refreshHomepage() {
20 revalidateTag("homepage-data");
21}

A mismatch between the Data Cache and the Full Route Cache can produce stale content. If the Data Cache is refreshed but the Full Route Cache is not, the page may still serve old HTML that was generated from old data. Conversely, if the Full Route Cache is revalidated but the Data Cache still holds old values, the regenerated page will use the old data. For predictable behavior, keep revalidation values aligned or use tags to invalidate both layers together.

Non-fetch data sources, such as direct database queries through an ORM, do not automatically use the Data Cache. You can cache them explicitly with unstable_cache or React's cache function. Without explicit caching, these queries run on every render, which can make an ISR page behave more like an SSR page than you intended.

react-cache.tsx
TSX
1import { cache } from "react";
2
3// React cache deduplicates requests within the same render pass
4const getCachedPost = cache(async (slug: string) => {
5 return db.post.findUnique({ where: { slug } });
6});
7
8export default async function PostPage({
9 params,
10}: {
11 params: Promise<{ slug: string }>;
12}) {
13 const { slug } = await params;
14 const post = await getCachedPost(slug);
15
16 return <article>{post?.content}</article>;
17}
โ„น

info

When debugging stale content, check which cache layer is holding the old value. Use the X-Nextjs-Cache and X-Nextjs-Prerender response headers during development to see whether a response was served from the Full Route Cache or rendered dynamically.
Common Pitfalls

Static generation and ISR are powerful but easy to misuse. The following pitfalls are responsible for most production issues: unexpected dynamic rendering, stale data, broken personalization, and confusing cache behavior.

1. Calling cookies() or headers() in a static layout. If a parent layout reads cookies, every child route becomes dynamic. Move auth and personalization into the routes that actually need it, or use middleware to gate access without forcing SSR.

2. Expecting ISR to be real-time. ISR is stale-while-revalidate, not real-time. If you need immediate consistency, use SSR or on-demand revalidation. Time-based revalidation is bounded by the interval you choose.

3. Using ISR with authenticated pages. Authenticated pages usually need SSR because the content depends on the user's session. Rendering a static version and trying to patch it client-side leads to layout shift and privacy leaks.

4. Misunderstanding dynamicParams. A route with dynamicParams = false and an unknown segment will 404, even if the data exists. A route with dynamicParams = true will render on demand but may not be statically optimized for the first request.

5. Ignoring cache layer coordination. Revalidating the Full Route Cache without revalidating the Data Cache can result in regenerated pages that still use old data. Use tags or align revalidation intervals to keep caches consistent.

6. Returning large generateStaticParams arrays. Pre-rendering every possible path at build time can make deployments slow and expensive. Render popular paths and let the rest render on demand.

7. Forgetting the build-time environment. generateStaticParams and static fetches run at build time. They cannot access runtime headers, cookies, or environment variables that are only present on the deployed server. Use environment variables that are available during the build.

8. Hardcoding secrets in revalidate webhooks. Protect webhook endpoints with secrets or signatures. Exposed revalidation endpoints can be used to force expensive work or cause cache churn.

9. Confusing Router Cache with server cache. The client-side Router Cache can keep a stale page visible after server revalidation. Use router refresh or invalidation APIs when the UI must update immediately.

10. Not handling build errors from missing data. If generateStaticParams fetches data and the CMS is down, the build fails. Make data fetching resilient, or fall back to dynamic rendering for non-critical paths.

โœ•

danger

Never cache pages that contain user-specific data by default. A static page that accidentally includes a private dashboard or account information can be served to the wrong visitor. Always verify that static generation is appropriate for the content before enabling it.
Best Practices

A well-implemented static generation strategy is more than a single revalidate value. It is a system of tags, fallback behavior, monitoring, and architectural boundaries. These practices help you scale static generation without surprises.

1. Use tags for every data fetch. Tags are the cheapest way to create a precise invalidation graph. Name them consistently, such as post-{slug}, category-{slug}, and global.

2. Separate fast-changing data from slow-changing data. A page header that changes hourly should not invalidate the entire page. Fetch it client-side or use a shorter revalidation interval for just that fetch.

3. Monitor cache hit rates and revalidation frequency. High revalidation rates can indicate that intervals are too short or that on-demand events are too noisy. Use server logs and analytics to tune intervals.

4. Pre-render only the paths that matter. Use analytics to find the top pages by traffic and render those at build time. Let long-tail paths render on demand with dynamicParams = true.

5. Keep fallback strategies explicit. Document whether a route returns 404 or renders on demand for unknown segments. Ambiguity here causes SEO and UX bugs.

6. Use draft mode for CMS previews, not production logic. Draft mode is a preview escape hatch, not a feature flag or authentication mechanism.

7. Align revalidation values across the route and its fetches. A mismatch between the Full Route Cache and the Data Cache is the most common cause of "it still looks stale" bugs.

8. Protect revalidation endpoints. Use secrets, signatures, or IP allowlists. Treat them as write operations, not public read endpoints.

9. Test build behavior in a staging environment. Some static generation bugs only appear when the build runs against real CMS data, such as missing segments or rate limits.

10. Document your rendering strategy. In a large app, different routes use different strategies. A short README or inline comments help reviewers understand why a route is static, ISR, or SSR.

๐Ÿ”ฅ

pro tip

Static generation is a caching strategy, not a religion. Use it where it wins, and switch to SSR or client-side fetching where it does not. The goal is always the fastest, freshest page for the user, not the most static one.
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXT-STATICยทRevision: 1.0