Skip to main content

How static site generation (SSG) works in Next.js

Static Site Generation (SSG) in Next.js pre-renders pages into HTML files during next build, then serves them from a CDN with no server code running per request.

Theory

TL;DR

  • SSG = baking all the cookies before the shop opens; visitors grab one instantly, no oven time per customer
  • Main difference from SSR: rendering happens at build time, not on every request
  • TTFB: ~10ms from CDN vs 100-500ms for SSR
  • Use SSG when content changes less than once a day and speed or SEO matters
  • cookies() or searchParams inside a component will silently opt that page out of SSG during build

Quick Example

tsx
// app/page.tsx - runs once at build time, outputs static HTML export default async function HomePage() { const data = await fetch('https://api.example.com/posts/1', { cache: 'force-cache' // locks the request to build time }).then(res => res.json()); return ( <div> <h1>{data.title}</h1> <p>{data.body}</p> </div> ); } // Output: .next/server/app/index.html with data baked in // On request: CDN returns HTML in ~10ms, JS hydrates the client

This component runs once during next build. The API response is embedded in the HTML. When users visit, the server is not involved at all.

Build Time vs Request Time

SSR runs Node.js on every request. SSG runs it exactly once. That single difference is what lets SSG serve pages in under 100ms from a CDN edge, while SSR still needs 100-500ms for the server roundtrip. The tradeoff: SSG data is frozen until the next build or ISR revalidation.

When to Use SSG

  • Documentation, blogs, marketing pages: stable content, SEO matters, traffic can spike unexpectedly
  • Product catalogs with weekly updates: SSG with ISR (revalidate: 60) covers this well
  • High traffic on a tight server budget: SSG removes runtime compute costs entirely
  • User dashboards or personalized pages: skip SSG, use SSR or CSR instead
  • Real-time data (stock prices, live chat): SSG is not an option here

Comparison Table

SSGSSRCSR
Render timenext buildEvery requestBrowser
TTFB~10ms (CDN)100-500ms2-5s (JS bundle)
Server costNone after deployScales with trafficMinimal (static host)
SEOFull pre-rendered HTMLFull pre-rendered HTMLWeak (needs hydration)
Data freshnessFrozen until rebuild or ISRAlways freshOn-demand
Next.js APIDefault App Router / getStaticPropsgetServerSidePropsuseEffect
Best forDocs, blogs (Vercel.com)Dashboards (Stripe.com)SPAs (Gmail)

How Next.js Builds Static Pages

During next build, Next.js scans every page, runs React Server Components in Node.js, fetches data via fetch with cache: 'force-cache', serializes the React tree to HTML using renderToPipeableStream, and writes .html files to .next/server/app. After that the server does nothing on requests. The CDN handles everything.

For dynamic routes, generateStaticParams tells the build which paths to pre-render:

tsx
// app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return posts.map((post: { slug: string }) => ({ slug: post.slug })); } export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await fetch(`https://api.example.com/posts/${params.slug}`, { cache: 'force-cache' }).then(r => r.json()); return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> </article> ); } // Build generates: /blog/my-first-post/index.html, /blog/next-tips/index.html, ...

Without generateStaticParams on a dynamic route, Next.js falls back to SSR. That is a common performance regression teams catch late.

ISR: Static Pages That Can Update

ISR (Incremental Static Regeneration) lets you revalidate a page in the background without a full rebuild. Add export const revalidate = 60 to revalidate every 60 seconds:

tsx
// app/blog/[slug]/page.tsx export const revalidate = 60; // background revalidation every 60s export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await fetch(`https://api.example.com/posts/${params.slug}`, { next: { revalidate: 60 } }).then(r => r.json()); return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ); } // First request after 60s: serves stale HTML, regenerates in background // Second request: gets fresh HTML

ISR is what makes "mostly static" content practical. Stripe's API Reference docs use this pattern to stay current without rebuilding everything.

Common Mistakes

Fetch without cache: 'force-cache' in App Router

tsx
// Wrong: may default to no-store in some Next.js 14+ configurations const data = await fetch('/api/data'); // Correct const data = await fetch('/api/data', { cache: 'force-cache' }); // Or at the page level: export const dynamic = 'force-static';

Without force-cache, the page becomes a dynamic render. Build logs show ⚠ Dynamic and the CDN has nothing to cache.

Missing generateStaticParams on dynamic routes

tsx
// app/shop/[id]/page.tsx with no generateStaticParams // Result: SSR fallback, zero pre-built HTML

Every dynamic route without generateStaticParams automatically becomes SSR. Add it or accept the tradeoff consciously.

Using cookies() in a component that looks static

tsx
// This component looks static but it is not export default async function Dashboard({ params }: { params: { id: string } }) { const isLoggedIn = !!cookies().get('session')?.value; // forces dynamic rendering return <div>User {params.id}</div>; }

cookies() marks the page dynamic = 'force-dynamic' during build analysis. The page becomes SSR regardless of generateStaticParams. Check next build logs for "Dynamic Function" warnings. I have seen this catch teams off guard when they already had generateStaticParams set up and assumed everything was static.

revalidate: 0 in ISR

Setting revalidate: 0 disables static generation entirely. Use revalidate: false for pure static or any positive number for ISR.

Mutating data in getStaticProps (Pages Router)

tsx
// Wrong export async function getStaticProps() { const data = await fetch('/api').then(r => r.json()); data.count++; // mutation is ignored or crashes the build return { props: { data } }; } // Correct export async function getStaticProps() { const data = await fetch('/api').then(r => r.json()); return { props: { data: { ...data, count: data.count + 1 } } }; }

getStaticProps should be pure. Transform immutably.

Real-World Usage

  • Vercel.com docs: SSG with ISR, 10M+ monthly visitors, zero server compute on page requests
  • TailwindUI.com: generateStaticParams pre-builds 500+ component preview pages
  • Stripe.com docs: SSG + ISR for API reference updates without full rebuilds
  • TinaCMS + Next.js: headless CMS triggers rebuilds on content change, a classic Jamstack setup

Follow-Up Questions

Q: How does Next.js decide whether to render a page statically or dynamically?
A: During next build, it scans each page for dynamic functions: cookies(), headers(), searchParams. Finding any of these marks the page force-dynamic. Build output shows ○ (Static) vs λ (Dynamic) for each route.

Q: What is the difference between export const revalidate = 60 and next: { revalidate: 60 } in fetch?
A: Page-level revalidate sets a default for the whole route. Fetch-level next.revalidate overrides per request and takes precedence, so one page can have one slow-revalidating fetch and one fast one.

Q: Why does cookies() prevent SSG?
A: It signals that the page depends on per-request context. Next.js cannot pre-render something that differs per user, so it marks the page dynamic = 'force-dynamic' and skips static generation entirely.

Q: ISR vs full rebuild: when does each make sense?
A: ISR regenerates single pages in the background with no downtime. A full rebuild touches everything at once and is faster for small sites. For 50 pages, a full rebuild is fine. For 10,000 product pages, ISR is the only practical path.

Q: What happens to paths not listed in generateStaticParams?
A: By default (dynamicParams = true), unknown paths fall back to SSR. Set export const dynamicParams = false to return 404 for anything not pre-built at build time.

Examples

Basic SSG Page in App Router

tsx
// app/docs/page.tsx export default async function DocsPage() { const docs = await fetch('https://api.itlead.org/docs', { cache: 'force-cache' }).then(res => res.json()); return ( <ul> {docs.map((doc: { slug: string; title: string }) => ( <li key={doc.slug}>{doc.title}</li> ))} </ul> ); } // Build output: docs/index.html with all titles baked in // CDN serves this file directly, no Node.js involved on request

cache: 'force-cache' locks the response to build time. This is the simplest SSG setup in App Router.

Dynamic Routes with generateStaticParams

tsx
// app/docs/[slug]/page.tsx export async function generateStaticParams() { const docs = await fetch('https://api.itlead.org/docs').then(r => r.json()); return docs.map((doc: { slug: string }) => ({ slug: doc.slug })); } export default async function DocPage({ params }: { params: { slug: string } }) { const doc = await fetch(`https://api.itlead.org/docs/${params.slug}`, { cache: 'force-cache' }).then(res => res.json()); return ( <article> <h1>{doc.title}</h1> <div>{doc.content}</div> </article> ); }

generateStaticParams runs once at build time, returns all slugs, and Next.js generates one HTML file per slug. At runtime it is pure CDN, no server.

Pages Router with getStaticProps and getStaticPaths

tsx
// pages/docs/[slug].tsx export async function getStaticPaths() { const docs = await fetch('https://api.itlead.org/docs').then(r => r.json()); return { paths: docs.map((doc: { slug: string }) => ({ params: { slug: doc.slug } })), fallback: false // 404 for any unknown slug }; } export async function getStaticProps({ params }: { params: { slug: string } }) { const doc = await fetch(`https://api.itlead.org/docs/${params.slug}`).then(r => r.json()); return { props: { doc } }; } export default function DocPage({ doc }: { doc: { title: string; content: string } }) { return ( <article> <h1>{doc.title}</h1> <p>{doc.content}</p> </article> ); }

fallback: false means unlisted slugs return 404 immediately. Switch to fallback: 'blocking' if you want SSR fallback for new content added after the build.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?