When a page is rendered on request, the server repeats the same work for every visitor: fetch the data, build the HTML, send it back. If 1,000 people open the same blog post, that work happens 1,000 times, and the result is the same every time.
Static Site Generation (SSG) moves that work to build time. The HTML is made once, stored, and served as a plain file. You will see:
- when a page needs getStaticProps, getStaticPaths, or neither,
- how ISR keeps built pages up to date, on a timer or when your CMS says so,
- what fallback does for pages you did not build,
- and when SSG is the wrong choice.
The examples use the Next.js Pages Router. If you use the App Router, the ideas are the same, and a table near the end maps each name to its App Router version.
Rendering on every request vs once
Building the HTML on each request is called server-side rendering (SSR).
Think of a restaurant. SSR cooks each dish after the customer orders it. SSG cooks the whole menu before the doors open, so every customer is served at once. That only works if every customer wants the same dish.
So for each page, ask one question: can I build this page before anyone asks for it?
- Yes: the page is the same for every visitor and the data exists ahead of time. Blog posts, docs, marketing pages and product listings fit. Use SSG: the page loads fast and search engines get the full HTML.
- No: the page depends on who is asking, or the data changes every few seconds. Dashboards, carts and stock prices fit here. The last sections cover what to use instead.
Pages with no data
This is the default. If a page has no getServerSideProps or getInitialProps, Next.js renders its HTML during next build and serves that file as it is.
export default function About() { return <h1>About this blog</h1>;}The server does no work per request, so the page can sit on a CDN (content delivery network): a set of servers around the world that keep copies of your files, so each visitor is served from one nearby.
Pages that need data: getStaticProps
Most pages need data from somewhere else, such as posts from a headless CMS (a content management system that only serves data, with no pages of its own). getStaticProps fetches that data at build time and passes it to your page as props. It always runs on the server, never in the browser, so it can use secrets and talk to a database directly.
All samples in this post read the CMS through one small helper. It fails loudly when the CMS answers with an error, so a broken build tells you why instead of crashing on an HTML error page it tried to parse as JSON.
export interface Post { slug: string; title: string; body: string;}const CMS_URL = process.env.CMS_URL;export async function getPosts(): Promise<Post[]> { const res = await fetch(`${CMS_URL}/posts`); if (!res.ok) throw new Error(`CMS /posts answered ${res.status}`); return res.json();}// A missing post is a normal answer, not an error.export async function getPost(slug: string): Promise<Post | null> { const res = await fetch(`${CMS_URL}/posts/${encodeURIComponent(slug)}`); if (res.status === 404) return null; if (!res.ok) throw new Error(`CMS /posts/${slug} answered ${res.status}`); return res.json();}The blog index then lists every post:
import type { GetStaticProps, InferGetStaticPropsType } from 'next';import { getPosts, type Post } from '@/lib/cms';export const getStaticProps: GetStaticProps<{ posts: Post[] }> = async () => { const posts = await getPosts(); return { props: { posts } };};export default function Blog({ posts }: InferGetStaticPropsType<typeof getStaticProps>) { return ( <ul> {posts.map((post) => ( <li key={post.slug}>{post.title}</li> ))} </ul> );}During the build, Next.js writes 2 files for this page: the HTML, and a JSON file with the props. The JSON is used when a visitor reaches the page from another page of your site through a Next.js <Link>, so the browser only downloads the data, not the whole page again. A CDN can cache both.
Pages whose URLs come from data: getStaticPaths
One post per page means a dynamic route: a file like pages/blog/[slug].tsx, where the slug (the readable last part of the URL, like new-post) decides which post to show. To build these pages ahead of time, Next.js has to know every slug during the build. The list lives in the CMS, so you cannot write it by hand.
getStaticPaths returns that list. Next.js then calls getStaticProps once per path to fetch each post.
import type { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from 'next';import { getPost, getPosts, type Post } from '@/lib/cms';export const getStaticPaths: GetStaticPaths = async () => { const posts = await getPosts(); return { // Route params must be strings. paths: posts.map((post) => ({ params: { slug: post.slug } })), // Any slug not in this list gets a 404. fallback: false, };};export const getStaticProps: GetStaticProps<{ post: Post }, { slug: string }> = async ({ params }) => {Pages you did not build: fallback
You publish a new post after the build. Someone opens its URL. What happens depends on fallback, the second value getStaticPaths returns:
- false: the visitor gets a 404 until you build again. Good for small sites that rebuild on every change.
- true: the visitor gets a loading version of the page right away, and the full page once getStaticProps finishes. Good when you have too many pages to build them all.
- 'blocking': the visitor waits while the server builds the page, then gets the full page. No loading state, but the first visit is as slow as SSR.
With true or 'blocking', the page is saved after the first request, and every later visitor gets it from the cache. Replay each mode below.
With fallback: true, your component renders once with empty props. Check router.isFallback first, or the code will crash on the missing post:
import { useRouter } from 'next/router';// Other imports, getStaticProps and getStaticPaths (now with fallback: true) as above.export default function BlogPost({ post }: InferGetStaticPropsType<typeof getStaticProps>) { const router = useRouter(); // The first visitor of an unbuilt page sees this until the props arrive. if (router.isFallback) return <p>Loading post...</p>; return ( <article> <h1>{post.title}</h1> <p>{post.body}</p> </article> );}Search engine crawlers never get the loading version: for them, Next.js handles fallback: true like 'blocking', so they always read the full page.
Keeping pages fresh with ISR
The catch with SSG is that a page only changes when you build again. Fallback adds new pages, but it never updates a page that already exists. Fix a typo in the CMS, and the site keeps the typo.
Incremental Static Regeneration (ISR) fixes that. Add revalidate, a number of seconds, to what getStaticProps returns:
export const getStaticProps: GetStaticProps<{ posts: Post[] }> = async () => { const posts = await getPosts(); return { props: { posts }, revalidate: 60, };};Here is what happens after the build:
- For 60 seconds, every visitor gets the cached page.
- After that, the next visitor still gets the cached page, now stale. Their request starts a rebuild in the background.
- When the rebuild succeeds, the new page replaces the old one, and the next visitors get it. If it fails, the old page stays.
So revalidate is not "update every 60 seconds". Nothing happens without a visitor, and the visitor who starts the rebuild gets the old version. Move the CMS edit and the revalidate time below, and compare ISR with SSG and SSR.
A shorter revalidate means fewer stale visitors and more server renders. A longer one means the opposite.
On-demand revalidation
A timer guesses when the content changed. Your CMS knows. Most headless CMSs can call a URL when content is published, a so-called webhook. Point it at an API route that rebuilds exactly the pages that changed:
import type { NextApiRequest, NextApiResponse } from 'next';export default async function handler(req: NextApiRequest, res: NextApiResponse) { // Only the CMS knows this secret, so nobody else can trigger rebuilds. if (req.query.secret !== process.env.REVALIDATE_SECRET) { return res.status(401).json({ message: 'Invalid secret' }); } // The body shape depends on your CMS. Here it sends the slug of the changed post. const slug = req.body?.slug; if (typeof slug !== 'string') { return res.status(400).json({ message: 'Missing slug' }); }Pass the real path, such as /blog/new-post, not the file name /blog/[slug]. If you only revalidate on demand, you can leave revalidate out of getStaticProps: pages then change only when the CMS calls. Keeping a long revalidate as well is a safety net for a webhook that never arrives.
On-demand revalidation only rebuilds paths that can exist. With fallback: false, as in the [slug] sample above, a new post's URL stays a 404 until the next build, and revalidating it fails. To publish new posts without a rebuild, set fallback to 'blocking' or true.
When SSG is the wrong choice
If the answer to "can I build this before anyone asks?" is no, you have 2 options.
Server-side rendering with getServerSideProps. The server fetches the data and builds the page on every request. The page is always current. By default nothing is cached, so each visit costs a render. If the page is the same for everyone, you can still let a CDN cache it for a short time with a header:
import type { GetServerSideProps } from 'next';import { getPrices } from '@/lib/prices';export const getServerSideProps: GetServerSideProps = async ({ res }) => { res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300'); return { props: { prices: await getPrices() } };};Never do that for a page that is personal to one user, or one visitor's page will be served to the next.
A static page with client-side fetching. Build the shared part of the page with SSG, and let the browser fetch the personal or live part with JavaScript after it loads. The page appears fast from the CDN, and the visitor sees a short loading state where the live data goes. This fits a public page with a small personal corner, such as a product page with "your cart" in the header.
SSG, ISR, SSR and CSR side by side
| Approach | HTML is built | Data is as fresh as | Good for |
|---|---|---|---|
| SSG | Once, at build time | The last build | Docs, marketing pages, blogs that rebuild on publish |
| ISR | At build time, then again in the background | The revalidate time, or the last CMS webhook | CMS-backed blogs, product listings, news |
| SSR | On every request | The request | Per-user pages, search results, live data |
| CSR (client-side rendering) | The shell at build time, the data in the browser | The browser's fetch | Personal or live parts of an otherwise public page |
The same ideas in the App Router
The App Router (the app folder) has no getStaticProps or getStaticPaths. Pages are async server components (components that run only on the server) that fetch their own data, and caching is set where you fetch. The concepts map like this:
| Pages Router | App Router |
|---|---|
| getStaticProps | An async page component that fetches its data. The page is prerendered at build time as long as it does not read request data such as cookies or headers. |
| getStaticPaths | generateStaticParams, which returns an array like [{ slug: 'a' }] |
| revalidate: 60 | export const revalidate = 60 in the page, or fetch(url, { next: { revalidate: 60 } }) |
| fallback: false | export const dynamicParams = false |
| fallback: true or 'blocking' | dynamicParams = true, the default. Unknown params are rendered on demand and cached. |
| res.revalidate('/blog/x') | revalidatePath('/blog/x') in a route handler (the App Router version of an API route) or a server action (a server function you call from a form or button) |
What to keep
- Build ahead whenever the page is the same for everyone. getStaticProps for the data, getStaticPaths for the list of URLs.
- Add revalidate or on-demand revalidation when content changes. Remember that the first visitor after the timer still gets the old page.
- Use SSR or client fetching only for per-user or live data, and keep the rest of the page static.
