Static Site Generation

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. It covers the App Router in depth and the Pages Router (getStaticProps, getStaticPaths, Automatic Static Optimization) side by side. Caching only matters here where it changes the result, and every example says which model it uses: the previous model (the default: a route is prerendered unless it reads request data) or Cache Components (the opt-in cacheComponents: true model, in which a static shell is prerendered and everything else is dynamic unless you cache it). Every example was built with next build and served with next start (Node.js 24.16, macOS), the static exports were also served from a plain file server, and the results quoted below are what the build or curl printed. The scratch app is a copy of the Bookshelf — the catalogue of books, authors and reviews that this section grows — and each example is followed by the official page it derives from.

Static site generation (SSG) means that the HTML of a page is produced once, at build time, written to disk, and then served unchanged to every visitor, usually from a CDN. This page explains why that is Next.js’s default behaviour, how generateStaticParams turns one dynamic route into many files, what happens to a path that was not built, how to ship nothing but files with output: 'export', and what the same ideas look like in the Pages Router. It builds on Rendering Fundamentals (the spectrum of rendering strategies) and Dynamic Routes and Route Groups (the [param] folders that generateStaticParams fills in). Its neighbours are Server-Side Rendering (the per-request opposite), and the pages on client-side rendering, incremental static regeneration, hydration and partial prerendering, which are written alongside this one; they are only mentioned here, not linked. For the React ideas underneath, see Server Rendering, SSG and React Server Components.

What SSG Is and Why It Is the Default

Next.js does not ask you to choose "static" for a page. It renders a route at build time unless something in it needs a request, and that is called prerendering (the official glossary lists "static rendering" as another name for it). A route that never reads cookies(), headers(), searchParams, or uncached data stays static, and next build marks it with a hollow circle in the route table. So "SSG" in Next.js is mostly a property of what your code does not do, not a switch you flip.

What the build writes for a static route is a bundle of files, not just HTML:

  • the HTML for the first load, rendered from the Server Components (and, for Client Components, the server render of them);

  • the RSC payload, React’s serialised description of the same tree, which the browser fetches instead of HTML when a user navigates to the page with next/link (the page on Rendering Fundamentals describes both);

  • a small meta file with the status and the headers to send, and, per route segment, segment payloads that make prefetching cheap.

The scratch app’s .next/server/app/books folder after next build shows exactly that, one set per generated path:

The files next build writes for one statically generated route
$ ls .next/server/app/books
1.html  1.meta  1.rsc  1.segments
2.html  2.meta  2.rsc  2.segments
3.html  3.meta  3.rsc  3.segments
4.html  4.meta  4.rsc  4.segments
...
$ ls .next/server/app/books/1.segments
_full.segment.rsc  _tree.segment.rsc  books

Static generation is the cheapest and fastest strategy there is: no server work per request, a response that a CDN can cache for as long as you like, and nothing to scale. Its price is that the output is the same for everybody and only as fresh as the last build (or, with the next page’s subject, the last revalidation). Use it for content that is public and changes rarely: marketing pages, documentation, a catalogue, blog posts. The decision table below puts it next to the other strategies; the rendering spectrum is covered in depth on the Rendering Fundamentals page.

Table 1. Where SSG sits among the rendering strategies
Strategy When the HTML is produced Same for every visitor? Typical use

SSG (prerendering)

At next build.

Yes.

Catalogue, docs, marketing pages, blog.

ISR (static plus revalidation)

At build, then again in the background after a time or an event.

Yes.

A catalogue whose data changes between deploys.

SSR (dynamic rendering)

On every request.

No, it may depend on cookies, headers, searchParams.

"My shelf", a search page, anything personalised.

CSR

In the browser, after the first load.

No.

Highly interactive panels behind a login.

The Build Pipeline

Everything on this page happens inside next build, so it helps to see the stages once. The stage names below are the ones the 16.3.8 CLI prints.

flowchart LR A["next build"] --> B["Compile with Turbopack:
server and client bundles"] B --> C["Type check"] C --> D["Collect page data:
read segment config,
call generateStaticParams"] D --> E["Generate static pages:
a worker pool renders
every route and param"] E --> F["Write .next/server/app:
html, rsc, meta, segments"] F --> G["Print the route table"] F --> H{"output: export?"} H -->|yes| I["Copy to out/:
HTML, RSC txt, assets, public/"] H -->|no| J["next start serves the files and
renders unlisted params on demand"]
Figure 1. From `next build` to files on disk

Two details matter later. First, generateStaticParams runs in the collect page data stage, before any page or layout is generated, so a failure there stops the build before a single page is rendered. Second, the generate static pages stage is where your Server Components actually execute, in a pool of worker processes (nine on the machine used for this page); every statically rendered route and every param combination is one unit of work, and this stage is the build-time cost the section on Build Output and Build-Time Cost measures.

This is the real output for the Bookshelf catalogue built below:

The build stages and the route table (previous model)
$ npx next build
  Creating an optimized production build ...
✓ Compiled successfully in 138ms
  Finished TypeScript in 436ms ...
  Collecting page data using 9 workers ...
  Generating static pages using 9 workers (0/19) ...
✓ Generating static pages using 9 workers (19/19) in 139ms

Route (app)
┌ ○ /
├ ○ /_not-found
├   /api/books/[id]
│ ├ ● /api/books/1
│ ├ ● /api/books/2
│ ├ ● /api/books/3
│ └ ● /api/books/4
├ ○ /books
├ ○ /books.json
├   /books/[id]
│ ├ ● /books/1
│ ├ ● /books/2
│ ├ ● /books/3
│ └ ● /books/4
├ ○ /robots.txt
├   /shelves/[genre]/[id]
│ ├ ● /shelves/scifi/1
│ ├ ● /shelves/scifi/2
│ ├ ● /shelves/scifi/3
│ └ ● /shelves/classic/4
└ ○ /sitemap.xml


○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)

Source: CLI: next build

Read the symbols as follows. ○ is a route that is static and has no dynamic segment (/books, /robots.txt). ● is a route that was generated from generateStaticParams; the indented lines under a bracketed route are the concrete paths that were built. ƒ (not in this table, but you will see it next to anything that reads request data) means "dynamic, rendered on demand". With Cache Components on, a fourth symbol, ◐ (Partial Prerender), appears. Since 16.0 the table no longer prints per-route sizes or the "First Load JS" column; use next experimental-analyze (still labelled experimental in 16.3) or the bundle analyzer when you need numbers.

When a Route Is Static

The rule in the previous model: the route is prerendered unless a component reads a Request-time API (cookies(), headers(), draftMode(), searchParams), fetches with cache: 'no-store', or sets a dynamic segment config. In Cache Components the rule is different in an important way: any uncached data or request data is dynamic, and the build tells you where, instead of silently switching the whole route to dynamic. Both are described on Rendering Fundamentals; what this page needs is the consequences for a static page.

Everything evaluated during the build is frozen into the files. That includes the time, random numbers, and environment variables:

A statically generated page is a snapshot (previous model)
export default function Stamp() {
  // runs once, during next build: every visitor sees the same value
  return <p>Built at {new Date().toISOString()}</p>;
}

Requesting /books/1 twice, 1.5 seconds apart, returned the same Built at instant both times (2026-10-03T14:30:22.219Z), and /books/2, generated in the same build, differed from it only by milliseconds. The same page with Cache Components fails the build instead, which is the more helpful behaviour:

The same new Date() with cacheComponents: true
Error: Route "/books/[id]": Next.js encountered the unstable value `new Date()` while prerendering.

This value can change between renders, so it must be either prerendered or computed later.

Ways to fix this:
  - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call
  - [cache] Prerender and cache the value with `"use cache"`
  - [client] Render the value on the client with `"use client"`
  - [measure] If the value is for telemetry, use a timing API such as `performance.now()`

Learn more: https://nextjs.org/docs/messages/blocking-prerender-current-time

The same applies to environment variables: a process.env.X read in a statically rendered Server Component is replaced by its value at build time, so changing the variable on the server later changes nothing until you rebuild. Only the dedicated runtime-reading patterns (a dynamic route, connection()) read the environment per request. The page on configuration and environment variables explains the build-time versus runtime split in full.

Finally, static means public: whatever is in the HTML or the RSC payload is visible to everyone and can be cached by a CDN. Never put user-specific or secret data in a statically generated page.

generateStaticParams

A route with a dynamic segment (/books/[id]) is not static by itself, because Next.js does not know the values of id at build time. generateStaticParams supplies them. You export it from a page, a layout or a route file, and it returns an array in which each object fills in the dynamic segments of one path:

Table 2. The return type for each kind of dynamic segment
Route generateStaticParams returns

/books/[id]

{ id: string }[]

/shelves/[genre]/[id]

{ genre: string, id: string }[]

/docs/[…​slug]

{ slug: string[] }[]

Values are strings (or arrays of strings for catch-all segments), because they come from URLs; convert numeric ids with String(). The function replaces the Pages Router getStaticPaths (introduced as generateStaticParams in 13.0), and it is allowed to be async.

A single dynamic segment: the Bookshelf book page

This is the page used for every example that follows. The data layer is a stand-in for a database or a CMS; in a real project it would be the lib/books.ts of the Bookshelf, and getBook is wrapped in React’s cache so generateMetadata and the page share one lookup.

lib/books.ts (the data stand-in used on this page)
import { cache } from "react";

export type Book = {
  id: string;
  title: string;
  author: string;
  genre: string;
  priceCents: number;
};

const books: Book[] = [
  { id: "1", title: "Dune", author: "Frank Herbert", genre: "scifi", priceCents: 999 },
  { id: "2", title: "Neuromancer", author: "William Gibson", genre: "scifi", priceCents: 899 },
  { id: "3", title: "Solaris", author: "Stanislaw Lem", genre: "scifi", priceCents: 799 },
  { id: "4", title: "Emma", author: "Jane Austen", genre: "classic", priceCents: 599 },
];

export async function listBooks(): Promise<Book[]> {
  return books;
}

// cache() memoizes per render pass, so generateMetadata and the page share one lookup
export const getBook = cache(async (id: string): Promise<Book | undefined> => {
  return books.find((b) => b.id === id);
});

export async function listGenres(): Promise<string[]> {
  return [...new Set(books.map((b) => b.genre))];
}

export async function listBooksByGenre(genre: string): Promise<Book[]> {
  return books.filter((b) => b.genre === genre);
}
app/books/[id]/page.tsx (previous model)
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getBook, listBooks } from "@/lib/books";

// Runs during next build, before the page itself is generated
export async function generateStaticParams() {
  const books = await listBooks();
  return books.map((b) => ({ id: b.id }));
}

// Also evaluated at build time for every generated path: the <title> is in the static HTML
export async function generateMetadata({ params }: PageProps<"/books/[id]">): Promise<Metadata> {
  const { id } = await params;
  const book = await getBook(id);
  return {
    title: book?.title ?? "Not found",
    description: book ? `${book.title} by ${book.author}` : undefined,
  };
}

export default async function BookPage({ params }: PageProps<"/books/[id]">) {
  const { id } = await params;
  const book = await getBook(id);
  if (!book) notFound();
  return (
    <main>
      <h1>{book.title}</h1>
      <p>{book.author}</p>
      <p>Built at {new Date().toISOString()}</p>
    </main>
  );
}

next build prints ● /books/1 to ● /books/4 (the table above), and the files exist as 1.html to 4.html. curl against next start confirmed what is served:

What the server sends for a built path
$ curl -si localhost:3502/books/1 | head -5
HTTP/1.1 200 OK
x-nextjs-cache: HIT
Cache-Control: s-maxage=31536000
Content-Type: text/html; charset=utf-8

$ curl -s localhost:3502/books/1 | grep -o '<title>[^<]*'
<title>Dune | Bookshelf

x-nextjs-cache: HIT says the response came from the prerendered cache, and s-maxage=31536000 (one year) tells a CDN it may keep it, because in the previous model a static page never expires unless you revalidate it. (With Cache Components the header is s-maxage=900, stale-while-revalidate=…​ for the page below, because the "use cache" function’s default cacheLife profile revalidates every 15 minutes.) Two details in the code are deliberate: params is a promise and is awaited (synchronous access was removed in 16), and PageProps<'/books/[id]'> is a type that Next.js generates, so no hand-written prop types are needed.

During next dev the function is called whenever you navigate to the route, so a new item in your data shows up without a restart; during next build it runs once, before the pages; and during revalidation it is not called again. fetch calls with the same URL and options are memoized across generate-prefixed functions, layouts and pages; use React cache (as above) when the data does not come from fetch.

Several dynamic segments: top-down and bottom-up

For /shelves/[genre]/[id] there are two ways to produce the pairs. Bottom-up: the page returns both values ([{ genre: 'scifi', id: '1' }, …​]) in one function. Top-down: the parent layout produces the genres, and the child page’s generateStaticParams runs once for every genre the parent produced and receives that genre. You can generate params for the segment you are in and the ones above it, never the ones below.

app/shelves/[genre]/layout.tsx (top-down, parent)
import { listGenres } from "@/lib/books";

// Top down: this layout can only generate [genre]
export async function generateStaticParams() {
  const genres = await listGenres();
  return genres.map((genre) => ({ genre }));
}

export default async function ShelfLayout({ children, params }: LayoutProps<"/shelves/[genre]">) {
  const { genre } = await params;
  return (
    <section>
      <h2>Shelf: {genre}</h2>
      {children}
    </section>
  );
}
app/shelves/[genre]/[id]/page.tsx (top-down, child)
import { getBook, listBooksByGenre } from "@/lib/books";

// Runs once for every { genre } the parent generated; only parent params are passed in
export async function generateStaticParams({ params }: { params: { genre: string } }) {
  const books = await listBooksByGenre(params.genre);
  return books.map((b) => ({ id: b.id }));
}

export default async function ShelfBookPage({ params }: PageProps<"/shelves/[genre]/[id]">) {
  const { genre, id } = await params;
  const book = await getBook(id);
  return (
    <p>
      {genre}: {book?.title}
    </p>
  );
}

The build logged one call to the layout’s function (listGenres) and then one listBooksByGenre per genre (scifi, classic), and produced /shelves/scifi/1, /shelves/scifi/2, /shelves/scifi/3 and /shelves/classic/4. Note that the child’s params argument is a plain object holding only the parent’s params; it is not a promise, unlike the params prop of the page. Top-down avoids loading the whole catalogue to find the genres and lets each level ask a cheaper question; bottom-up is simpler when one query returns everything. When the parent segment is a root parameter, a nested generateStaticParams can also read it with the next/root-params getters (16.3, Server Components only).

Paths that were not built: dynamicParams

generateStaticParams does not have to list everything. The segment option dynamicParams decides what happens to a path that is not in the list:

Table 3. dynamicParams in the previous model
Value Behaviour for a path not returned by generateStaticParams

true (the default)

The page is rendered on the first request, then cached like any built page. An id that does not exist runs notFound() and returns a 404 that is not cached.

false

A 404, immediately, without running any of your code.

Source: dynamicParams

To see both, the page was rebuilt returning only the first two books (books.slice(0, 2)). The route table then lists only /books/1 and /books/2, and the first request for /books/3 was generated on demand:

A path that was not built, with dynamicParams = true (the default)
$ curl -sI localhost:3502/books/3 | grep -i -E "HTTP|x-nextjs-cache"
HTTP/1.1 200 OK
x-nextjs-cache: MISS
$ curl -sI localhost:3502/books/3 | grep -i -E "HTTP|x-nextjs-cache"
HTTP/1.1 200 OK
x-nextjs-cache: HIT
$ curl -sI localhost:3502/books/99 | grep -i -E "HTTP|x-nextjs-cache"
HTTP/1.1 404 Not Found
x-nextjs-cache: MISS

And with export const dynamicParams = false; added above generateStaticParams, /books/3 returned 404 at once (and no data function ran), while /books/1 stayed a 200 HIT:

Closing the list of paths
// Only the paths from generateStaticParams exist; everything else is a 404
export const dynamicParams = false;

export async function generateStaticParams() {
  const books = await listBooks();
  return books.slice(0, 2).map((b) => ({ id: b.id }));
}

The sequence on a first request for an unlisted path in the default mode:

sequenceDiagram participant V as Visitor participant S as Next.js server participant D as Data source V->>S: GET /books/3 (not built) S->>D: getBook("3") D-->>S: row S->>S: render, store as 3.html, 3.rsc, 3.meta S-->>V: 200, x-nextjs-cache: MISS V->>S: GET /books/3 S-->>V: 200, x-nextjs-cache: HIT (no render, no data call) V->>S: GET /books/99 S->>D: getBook("99") D-->>S: nothing S-->>V: 404, MISS (not stored)
Figure 2. A path that was not built, with `dynamicParams` true

Choosing between the two is a trade between build time and strictness. Return the whole list and keep the default when the catalogue is small; return the top items and leave dynamicParams on when the list is huge (the unlisted ones are built when first visited, which is how most large catalogues keep builds short); set false when the list is a closed set (supported locales, a fixed set of categories) and a 404 for anything else is exactly what you want. Because unknown ids are not cached, a crawler that enumerates ids makes your server hit the data source each time; false or a cheap existence check in front of the data call avoids that.

Two special forms are worth knowing. Returning an empty array defers every path to the first request (the build generates none of them), and so does export const dynamic = 'force-static':

Build nothing, generate everything on first request (previous model)
export async function generateStaticParams() {
  return []; // you must return an array, even an empty one; otherwise the route is dynamic
}

The build then printed ● /books/[id] with no children, and the first request for /books/2 was a MISS, the second a HIT.

With Cache Components: at least one param, and no dynamicParams

Cache Components changes three things here, all verified by building the same page with cacheComponents: true.

  1. generateStaticParams must return at least one param. An empty array fails the build:

    Error: When using Cache Components, all `generateStaticParams` functions must return at least one result. This is to
    ensure that we can perform build-time validation that there is no other dynamic accesses that would cause a runtime
    error.
    
    Learn more: https://nextjs.org/docs/messages/empty-generate-static-params

    The samples are what lets the build validate the route: it renders the page with each sample, and fails if something reads request data or uncached data outside <Suspense> or "use cache". A placeholder such as [{ id: '__placeholder__' }] that your page turns into notFound() silences the error but validates nothing.

  2. dynamicParams is rejected. Adding export const dynamicParams = false fails the build with the message "Route segment config dynamicParams is not compatible with nextConfig.cacheComponents. Please remove it." The option is not available because params that were not listed are served differently (below).

  3. export const dynamic = 'force-static' is rejected too, with the same message for "dynamic". Remove it; use "use cache" to say what is cacheable.

The Bookshelf page under Cache Components needs only the date moved into a cached function:

app/books/[id]/page.tsx (Cache Components: the changed parts)
async function builtAt() {
  "use cache";
  return new Date().toISOString();
}

export default async function BookPage({ params }: PageProps<"/books/[id]">) {
  const { id } = await params;
  const book = await getBook(id);
  if (!book) notFound();
  return (
    <main>
      <h1>{book.title}</h1>
      <p>{book.author}</p>
      <p>Built at {await builtAt()}</p>
    </main>
  );
}

The route table for that build:

The route table with Cache Components
├   /books/[id]
│ ├ ◐ /books/[id]
│ ├ ○ /books/1
│ ├ ○ /books/2
│ └ ◐ [+2 more paths]
...
○  (Static)             prerendered as static content
●  (SSG)                prerendered as static HTML (uses generateStaticParams)
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

The table abbreviates long lists ([+2 more paths]). The extra ◐ /books/[id] row is the App Shell: the part of the page that does not depend on the param. A path that was not listed (with only two samples, /books/3) was answered on the first request (x-nextjs-cache: MISS, then HIT) and /books/99 returned a 404. In 16.3 you can add partialPrefetching: true to the config so that an unlisted URL gets the shell instantly and is upgraded in the background; that is the Cache Components counterpart of fallback: true, and it is described on the page about incremental static regeneration.

If a route has no generateStaticParams, every param is runtime data under Cache Components, and reading params must happen inside <Suspense> (or loading.tsx) or the build fails with blocking-prerender-dynamic; the Streaming and Suspense page has the details.

A three column diagram titled A path that was not built: three ways to answer it. Column one, fallback false, matching dynamicParams false: the request for /books/3 gets a 404 page, no data function runs and nothing is cached; only a new next build adds the path. Column two, fallback true, whose closest App Router match is the App Shell of 16.3: the request gets fallback HTML at once with empty props and router.isFallback true, then the page is built in the background and swapped in, and the next request is a cache hit. Column three, fallback blocking, matching dynamicParams true which is the default: the server renders the page while the visitor waits, caches it as static HTML, and the next request is a hit; unknown ids give a 404. Measured with next start: 404; 200 fallback then 200 page; 200 MISS then 200 HIT. A bottom band says that in the App Router the single option dynamicParams, true by default, replaces fallback, that dynamicParams is rejected under Cache Components where generateStaticParams must return at least one param, and that unlisted params get the App Shell.
Figure 3. What happens to a path that was not built

In Route Handlers

generateStaticParams also works in a route.ts, and it is how you ship JSON or other files as static assets. The example from the scratch app:

app/api/books/[id]/route.ts (static JSON per book)
import { getBook, listBooks } from "@/lib/books";

export async function generateStaticParams() {
  return (await listBooks()).map((b) => ({ id: b.id }));
}

export async function GET(_request: Request, { params }: RouteContext<"/api/books/[id]">) {
  const { id } = await params;
  const book = await getBook(id);
  if (!book) return Response.json({ error: "not found" }, { status: 404 });
  return Response.json(book);
}
The handler is prerendered for each listed id
$ curl -si localhost:3502/api/books/2 | grep -i -E "HTTP|x-nextjs-cache|cache-control|content-type"
HTTP/1.1 200 OK
x-nextjs-cache: HIT
cache-control: s-maxage=31536000
content-type: application/json
$ curl -s localhost:3502/api/books/2
{"id":"2","title":"Neuromancer","author":"William Gibson","genre":"scifi","priceCents":899}

A GET Route Handler with no generateStaticParams and no config is not prerendered since 15.0 (it is dynamic; the build table shows ƒ). To make it a static file, mark it export const dynamic = 'force-static', as in the next example (previous model; under Cache Components the option is rejected, and a handler that reads nothing from the request is prerendered by default):

app/books.json/route.ts (a static file named books.json)
import { listBooks } from "@/lib/books";

export const dynamic = "force-static";

export async function GET() {
  return Response.json(await listBooks());
}

The build listed ○ /books.json, and the response was a HIT with cache-control: s-maxage=31536000. Only GET is static, and a handler that reads the Request cannot be.

Static Metadata and Metadata Files

Metadata is part of the page, so it is prerendered with it. The metadata object and generateMetadata are evaluated at build time for static routes (the <title>Dune | Bookshelf shown above comes from generateMetadata), and so are the file conventions that generate files: sitemap.ts, robots.ts, manifest.ts, opengraph-image and the icon files. Those Route-Handler-like files are static unless they use request data (sitemap.xml and robots.txt were ○ (Static) in the table above and served with x-nextjs-cache: HIT).

app/layout.tsx (a title template and an absolute base for relative URLs)
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: { default: "Bookshelf", template: "%s | Bookshelf" },
  metadataBase: new URL("https://bookshelf.example.com"),
};

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
app/sitemap.ts (generated from the same data as the pages)
import type { MetadataRoute } from "next";
import { listBooks } from "@/lib/books";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const books = await listBooks();
  return [
    { url: "https://bookshelf.example.com/", changeFrequency: "monthly" },
    ...books.map((b) => ({ url: `https://bookshelf.example.com/books/${b.id}` })),
  ];
}
app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/" },
    sitemap: "https://bookshelf.example.com/sitemap.xml",
  };
}

Under output: 'export' (below), these files must opt in with export const dynamic = "force-static", like any other Route Handler, or the build stops. A page whose metadata needs a database call, like the book title above, simply moves that call into the build.

Worked Example: the Bookshelf Catalogue as a Static Site

Putting the pieces together, this is the static part of the Bookshelf front end: a home page, a catalogue, one page per book, one page per shelf entry, a JSON feed, a sitemap and robots.txt.

The files of the static Bookshelf
app/
├── layout.tsx                    metadata template, metadataBase
├── page.tsx                      ○ /
├── sitemap.ts                    ○ /sitemap.xml
├── robots.ts                     ○ /robots.txt
├── books.json/route.ts           ○ /books.json  (dynamic = "force-static")
├── books/
│   ├── page.tsx                  ○ /books
│   └── [id]/page.tsx             ● /books/1 ... /books/4
├── shelves/[genre]/
│   ├── layout.tsx                generateStaticParams -> genres (top-down)
│   └── [id]/page.tsx             ● /shelves/scifi/1 ... /shelves/classic/4
└── api/books/[id]/route.ts       ● /api/books/1 ... /api/books/4
lib/books.ts                      the data stand-in
app/books/page.tsx (the catalogue: a plain static page)
import Link from "next/link";
import { listBooks } from "@/lib/books";

export const metadata = { title: "Catalogue" };

export default async function BooksPage() {
  const books = await listBooks();
  return (
    <main>
      <h1>Catalogue</h1>
      <ul>
        {books.map((b) => (
          <li key={b.id}>
            <Link href={`/books/${b.id}`}>{b.title}</Link> by {b.author}
          </li>
        ))}
      </ul>
    </main>
  );
}

The data call in BooksPage runs at build time and its result is baked into books.html; the data layer is therefore never reachable from the browser. The build’s route table is the one shown earlier. A request to next start shows that the list is already in the HTML, not fetched by JavaScript (React adds the <!-- -→ comments between adjacent text nodes):

The catalogue is in the HTML
$ curl -s localhost:3502/books | grep -o '<li>.*</li>' | head -c 140
<li><a href="/books/1">Dune</a> by <!-- -->Frank Herbert</li><li><a href="/books/2">Neuromancer</a> by <!-- -->Will...

The same app with CACHE_COMPONENTS=1 (the next.config.ts of the scratch app turns on cacheComponents from that variable) built after two edits: the dynamic = "force-static" line removed from books.json/route.ts, and the new Date() moved into a "use cache" function as above.

Static Export: output: 'export'

A static export goes one step further than prerendering: it produces a folder of HTML, CSS and JavaScript that any web server can host, with no Node.js server at all. Enable it in the config:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",

  // Optional: /books/1 becomes /books/1/index.html instead of /books/1.html
  // trailingSlash: true,

  // Optional: stop Next.js redirecting /me to /me/ (and the reverse)
  // skipTrailingSlashRedirect: true,

  // Optional: write to dist/ instead of out/
  // distDir: "dist",
};

export default nextConfig;

next build now also fills the out/ folder. This is the layout it wrote for the Bookshelf, with Next.js’s own bookkeeping files trimmed:

The out/ folder (default trailingSlash: false)
out/
├── index.html
├── 404.html
├── books.html
├── books.json
├── books/
│   ├── 1.html   1.txt   1/__next.books.$d$id.__PAGE__.txt  ...
│   └── 4.html   4.txt
├── shelves/scifi/1.html  ...  shelves/classic/4.html
├── api/books/1  ...  api/books/4        (JSON, no file extension)
├── robots.txt
├── sitemap.xml
└── _next/static/...                     (hashed JS and CSS)

With trailingSlash: true the same build wrote books/1/index.html, books/index.html and index.html per route (folders instead of sibling files), while books.json and robots.txt stayed plain files: URLs with a file extension never get a trailing slash. The .txt files are RSC payloads that the browser fetches when a user clicks a next/link; they must be served, with their extension, next to the HTML.

Do not run next start on an export. It refuses, and the message names the right tool:

Error: "next start" does not work with "output: export" configuration. Use "npx serve@latest out" instead.

What is supported

Server Components run during the build (their output becomes the HTML and a static payload for client navigation), Client Components work as in any SPA (with SWR or TanStack Query for data in the browser), and next/link, prefetching, dynamic imports, CSS Modules and the Metadata API work. Route Handlers work for GET when marked force-static. Browser-only APIs (window, localStorage) belong in useEffect because Client Components are also prerendered.

What is not supported, and what the build really does

The official list is below, extended with what Next.js 16.3.8 actually did in the scratch app. The important finding is that not every unsupported feature is a build error: several are silently dropped, so read this table before trusting a green build.

Table 4. Unsupported features under output: 'export', as observed on 16.3.8
Feature Observed in the build What to do

Dynamic route without generateStaticParams

Build error: Page "/bad/a/[slug]" is missing "generateStaticParams()" so it cannot be used with "output: export" config.

Provide the list of paths. (dynamicParams left at its default did not fail the build; unlisted paths simply do not exist in out/.)

cookies(), headers(), searchParams in a page

Build error: the route "couldn’t be rendered statically because it used cookies()`" (or `await searchParams).

Read them in a Client Component (useSearchParams), or do not export.

Route Handler (and sitemap.ts, robots.ts) without force-static

Build error: export const dynamic = "force-static"/export const revalidate not configured on route …​ with "output: export".

Add export const dynamic = "force-static". Handlers that read the Request cannot be exported.

Server Actions ("use server")

Build error: Server Actions are not supported with static export.

Call a separate API from Client Components.

Default next/image loader

next dev fails with Image Optimization using the default loader is not compatible with { output: 'export' }; next build succeeded and emitted src="/_next/image?url=%2Fcover.png&w=128&q=75", a URL no static host serves.

Set images.loader: "custom" (below) or images.unoptimized: true.

redirects, rewrites, headers in the config

Only a warning: rewrites, redirects, and headers are not applied when exporting your application.

Implement them on the host (nginx, CloudFront, _redirects, …​); see the page on Redirects, Rewrites and Proxy for the Next.js side.

Proxy (proxy.ts), Draft Mode, Intercepting Routes, ISR (revalidate)

Not tried; the official list marks them unsupported (a static host has no process to run them).

Rebuild and redeploy to change content; use a server deployment for any of these.

Pages Router: getServerSideProps

Build error: pages with getServerSideProps can not be exported.

Use getStaticProps, or do not export.

Pages Router: API routes

Only a warning (disables API routes and middleware); the route is listed as ƒ /api/x and nothing is written to out/.

Host the API elsewhere.

Pages Router: getStaticPaths with fallback: true or 'blocking'

The build succeeded and out/ contained only the listed paths (books/1.html, books/2.html); /books/3 does not exist on the host.

Use fallback: false. (The i18n routing config is also on the official unsupported list; it was not tried.)

Images: a custom loader

next/image needs a server to resize images, and an export has none, so you point it at an external image service with a custom loader. A loader is a function from (src, width, quality) to a URL:

next.config.ts (images in an export)
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  images: { loader: "custom", loaderFile: "./lib/image-loader.ts" },
};

export default nextConfig;
lib/image-loader.ts
// A custom loader maps (src, width, quality) to the URL of an external image service
export default function bookshelfLoader({ src, width, quality }: { src: string; width: number; quality?: number }) {
  return `https://images.bookshelf.example.com${src}?w=${width}&q=${quality ?? 75}`;
}

With a 50 by 50 pixel Image whose src is /cover.png, the exported HTML contained src="https://images.bookshelf.example.com/cover.png?w=128&q=75" and a matching srcSet. (images.unoptimized: true is the other route: the original file is served as it is.)

Hosting the out/ folder

Any file server works, provided it maps a clean URL to the right file. The official guide shows an nginx configuration; this is a shortened version of the same idea for the Bookshelf:

nginx.conf (adapted for the Bookshelf export, trailingSlash: false; not run)
server {
  listen 80;
  server_name bookshelf.example.com;

  root /var/www/out;

  # /books/1 -> books/1.html; /books/ -> books/index.html is only produced with trailingSlash: true
  location / {
    try_files $uri $uri.html $uri/index.html =404;
  }

  error_page 404 /404.html;
  location = /404.html {
    internal;
  }
}

Source: Static exports: deploying. For GitHub Pages, the official project provides a template at nextjs/deploy-github-pages.

Amazon S3 and CloudFront

The recommended AWS setup is a private S3 bucket behind CloudFront with Origin Access Control, which the S3 Security and Static Hosting page walks through end to end (the section "Hosting a static site: private bucket, CloudFront and OAC"), and the distribution itself is described on CloudFront CDN. Those pages host a single-page app: they send every 403/404 to /index.html with a 200. A Next.js export is not an SPA: each route has its own file, so that fallback would show the home page for a mistyped URL and hides real 404s. Use a custom error response with the response page path /404.html and keep the response code 404; with a private bucket and no s3:ListBucket permission S3 answers 403 for a missing key, so add the same response for 403 (the CloudFront page explains the permission).

The remaining gap is that CloudFront’s default root object only applies to /, and S3’s REST endpoint does not resolve /books/1 to books/1.html. A tiny CloudFront Function on the viewer request closes it. This function was run against the scratch export with a small Node.js file server that applies it, and returned the right file for every URL below:

cf-rewrite.js (CloudFront Function, viewer request, for trailingSlash: false)
// Map clean URLs to the files `next build` wrote to out/
function handler(event) {
  var request = event.request;
  var uri = request.uri;

  if (uri.endsWith("/")) {
    uri = uri.slice(0, -1);
  }
  if (uri === "") {
    request.uri = "/index.html";
  } else if (uri.lastIndexOf(".") < uri.lastIndexOf("/")) {
    // no file extension: /books/1 -> /books/1.html
    request.uri = uri + ".html";
  }
  return request;
}
Responses of the exported Bookshelf behind that rewrite
/               -> 200 text/html
/books          -> 200 text/html
/books/         -> 200 text/html
/books/2        -> 200 text/html
/books/99       -> 404 text/html   (the 404.html page)
/books.json     -> 200 application/json
/books/2.txt    -> 200 text/plain  (the RSC payload for client navigation)

Upload with the AWS CLI (placeholder bucket and distribution names; this part was not run against a real AWS account). Hashed assets under _next/static never change, so they can be cached for a year; HTML must be revalidated so a deploy is seen at once:

Deploying out/ to S3 and invalidating CloudFront
# hashed, immutable assets
aws s3 sync out/_next/static s3://BUCKET_NAME/_next/static \
  --cache-control "public,max-age=31536000,immutable"

# everything else: always revalidate
aws s3 sync out s3://BUCKET_NAME --delete --exclude "_next/static/*" \
  --cache-control "public,max-age=0,must-revalidate"

aws cloudfront create-invalidation --distribution-id DISTRIBUTION_ID --paths "/*"

Two things bite here. Extensionless files such as out/api/books/1 (the output of a dynamic Route Handler) get no content type from aws s3 sync (S3 serves them as binary/octet-stream) and the rewrite above would turn their URL into .html; avoid dynamic Route Handlers in an export, or give static files a real name (books.json). And the invalidation "/*" is the simplest choice but empties the whole cache, including hashed assets that did not change; the CloudFront page covers narrower invalidations and cache policies.

Google Cloud

On Google Cloud the equivalent is a Cloud Storage bucket behind a global external Application Load Balancer with Cloud CDN. The Cloud Storage Security and Static Sites page hosts the Bookshelf static site that way, and Cloud CDN and Media CDN covers the caching layer. The same two rules apply (this part was not run against a real project). Keep the 404 for unknown paths instead of sending them to index.html: set the bucket’s not-found page to 404.html (the status code stays 404). And make sure /books/1 reaches a file: export with trailingSlash: true so that the bucket’s main page suffix index.html serves /books/1/, or keep the default layout and add a URL-map rewrite for extensionless routes, as the Cloud CDN page suggests for single-page apps.

The Pages Router: getStaticProps, getStaticPaths and Automatic Static Optimization

In the pages/ directory SSG is a small set of explicit functions, and a page with none of them is static anyway.

Automatic Static Optimization

A page is statically optimised when it has no getServerSideProps and no getInitialProps. Nothing to opt in:

pages/index.tsx
// No getServerSideProps / getInitialProps: Automatic Static Optimization prerenders this page
import Link from "next/link";

export default function Home() {
  return (
    <main>
      <h1>Bookshelf</h1>
      <Link href="/books">Catalogue</Link>
    </main>
  );
}

The build printed ○ / and .next/server/pages/index.html exists (it would be index.js if the page had getServerSideProps). Caveats: a custom App with getInitialProps switches the optimisation off for pages without getStaticProps; during prerender the router’s query is empty and is filled in after hydration for dynamic routes, for pages whose URL has a query string, and when rewrites are configured, so wait for router.isReady before reading it; and avoid asPath before isReady, or you get a hydration mismatch.

getStaticProps

Export getStaticProps from a page to run code at build time and pass the result as props. It runs only on the server, never ships to the browser, and cannot read the request (no cookies, no query string); use Proxy in front of it if you must vary the output.

pages/books/index.tsx
import type { GetStaticProps, InferGetStaticPropsType } from "next";
import Link from "next/link";
import { listBooks, type Book } from "@/lib/books";

export const getStaticProps = (async () => {
  const books = await listBooks();
  return { props: { books } };
}) satisfies GetStaticProps<{ books: Book[] }>;

export default function Books({ books }: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <main>
      <h1>Catalogue</h1>
      <ul>
        {books.map((b) => (
          <li key={b.id}>
            <Link href={`/books/${b.id}`}>{b.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

Source: getStaticProps

The return value is an object with one of props, redirect (send the visitor elsewhere) or notFound: true (a 404), and optionally revalidate (seconds, which makes the page ISR). The props are serialised into the HTML as JSON and shipped to the browser so React can hydrate, which means nothing sensitive may go in props. next build writes both an HTML file and a JSON file per page; the HTML serves the first load and the JSON serves client navigation (/_next/data/<buildId>/books/3.json). In development getStaticProps runs on every request.

getStaticPaths and fallback

A dynamic page that uses getStaticProps must also export getStaticPaths, which lists the paths to build and says what to do with the rest. This is the file that was built and exercised with each of the three fallback values:

pages/books/[id].tsx
import type { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
import { useRouter } from "next/router";
import { getBook, listBooks, type Book } from "@/lib/books";

export const getStaticPaths = (async () => {
  const books = await listBooks();
  return {
    // only the first two books are built; the third is left to `fallback`
    paths: books.slice(0, 2).map((b) => ({ params: { id: b.id } })),
    fallback: "blocking", // false | true | "blocking"
  };
}) satisfies GetStaticPaths;

export const getStaticProps = (async ({ params }) => {
  const book = await getBook(params!.id as string);
  if (!book) return { notFound: true };
  return { props: { book } };
}) satisfies GetStaticProps<{ book: Book }, { id: string }>;

export default function BookPage({ book }: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter();
  // with fallback: true the first render has empty props
  if (router.isFallback) return <p>Loading...</p>;
  return (
    <main>
      <h1>{book.title}</h1>
      <p>{book.author}</p>
    </main>
  );
}

In all three runs the route table was the same (● /books/1, ● /books/2; getStaticPaths runs only at build time in production, and on every request in development). What differed was the answer for a path outside the list, measured with next start:

Table 5. The three fallback values, measured
fallback Request for /books/3 (exists, not built) and /books/99 (does not exist) How it works

false

/books/3: 404. /books/99: 404.

Only the paths returned are served. New data needs a new build.

true

/books/3 fetched as HTML: 200 with the Loading…​ markup (x-nextjs-cache: MISS), repeated until the data file /_next/data/<buildId>/books/3.json was requested, which returned the props; then 200 HIT with the real page. /books/99.json: 404 {"notFound":true}, then 404.

The fallback HTML has empty props and router.isFallback is true. The browser then fetches the data file, which triggers the build in the background; next/link navigation never shows the fallback and behaves like blocking.

"blocking"

/books/3: 200 with the full page on the first request (waiting, like SSR), then HIT. /books/99: 404.

Next.js server-renders the page, caches it, and serves it from the cache from then on. No loading state, so no flash.

Two documented behaviours the experiment did not need: crawlers are served the blocking behaviour even with fallback: true (so they do not index the loading state), and neither true nor 'blocking' ever updates a page that was generated; for that, add revalidate (ISR). Another rule: with fallback: true, your component must handle the empty-props render (the isFallback check above) or it crashes on book.title.

The Pages Router and the App Router side by side

Table 6. SSG in the two routers
Need Pages Router App Router

Prerender with no data

Automatic Static Optimization (no data function)

A page with no Request-time API is prerendered

Prerender with data

getStaticProps returns props

An async Server Component awaits the data

List the paths of a dynamic route

getStaticPaths returns { paths: [{ params }], fallback }

generateStaticParams returns [{ id }]

fallback: false

Only listed paths exist.

export const dynamicParams = false

fallback: 'blocking'

Wait, then cache.

The default (dynamicParams = true)

fallback: true

Fallback render with router.isFallback

The App Shell with Cache Components and partialPrefetching (16.3)

404 for a missing record

return { notFound: true }

notFound() from next/navigation

Redirect

return { redirect: { destination, permanent } }

redirect() from next/navigation

Regenerate after a time

revalidate: seconds in the returned object

revalidate segment config or cacheLife; the ISR page covers both

Where the data lands

props, serialised into the page and a JSON file

The RSC payload (only what Client Components receive as props is serialised)

Per-page <head>

next/head

metadata and generateMetadata

Static export of a dynamic route

getStaticPaths with fallback: false

generateStaticParams, plus force-static Route Handlers

staticGeneration* Configuration

Three experimental options tune the generate static pages stage. They live under experimental and the official page marks them as experimental, not recommended for production:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    staticGenerationRetryCount: 1,
    staticGenerationMaxConcurrency: 8,
    staticGenerationMinPagesPerWorker: 25,
  },
};

export default nextConfig;
Table 7. The staticGeneration* options
Option Meaning (official description)

staticGenerationRetryCount

How many times to retry a failed page generation before failing the build.

staticGenerationMaxConcurrency

The maximum number of pages processed per worker.

staticGenerationMinPagesPerWorker

The minimum number of pages processed before a new worker is started.

Next.js prints · staticGenerationRetryCount: 1 under "Experiments (use with caution)" when you set one. In the scratch app a page that threw a transient upstream failure error on its first attempt still failed the build with staticGenerationRetryCount: 1, so do not count on the retry to hide a flaky data source in your render code: make the data fetching itself resilient. The concurrency option has a measurable effect, shown next.

Build Output and Build-Time Cost

Static generation moves cost from the request to the build: a site with 10,000 products renders 10,000 pages every time you build. Three levers keep that bearable.

  • Build fewer paths. Return the top few hundred from generateStaticParams (or getStaticPaths) and leave dynamicParams: true (or fallback: 'blocking'); the rest are built on first visit and cached. The Pages Router guide even suggests returning paths: [] in preview environments to skip static generation there.

  • Make each page cheap. The data call is usually the cost. Memoize with fetch or React cache, fetch lists in one query rather than one per page, and keep generateStaticParams light.

  • Tune the pool. staticGenerationMaxConcurrency controls how many pages a worker handles at once, which matters when each page waits on a slow network call.

To see the third lever, a scratch route app/slow/[n] generated 300 params, each awaiting a simulated 200 ms upstream call, and the whole build was timed (Node.js 24.16, Turbopack, nine workers, macOS laptop; the numbers are for orientation only):

Table 8. Time of the "Generating static pages" stage for 319 pages that each wait 200 ms
Setting Stage time

Default

1.2 s

staticGenerationMaxConcurrency: 1

7.8 s

staticGenerationMaxConcurrency: 4

2.1 s

staticGenerationMaxConcurrency: 50

0.7 s

A build that waits on a network is latency bound, so more concurrency helps until the upstream throttles you; a build that does CPU work is worker bound, so extra concurrency only adds memory use. Measure on your own pages, and watch the upstream rate limits. Two more build-time facts: a parent segment’s generateStaticParams runs once and a child’s once per parent value, and a page that fails to render fails the whole build (Export encountered an error on /flaky/page: /flaky, exiting the build.), which is what you want for a catalogue but a reason to keep fragile third-party calls out of the build or behind a fallback.

What Changed Since Next.js 13 and 14

Table 9. SSG-relevant changes from 13 to 16.3
Version Change Effect on this page’s code

9.3

getStaticProps and getStaticPaths introduced.

Still the Pages Router way, unchanged.

13.0

generateStaticParams replaces getStaticPaths in the App Router; dynamicParams replaces fallback.

Return [{ id }] objects; no fallback.

13.3

next export deprecated in favour of output: 'export'.

A config option, built by next build.

13.4

The App Router became stable, with Route Handlers and Server Components in static exports.

Static export works without the Pages Router.

14.0

next export removed.

Only output: 'export' remains; old scripts next build && next export break.

15.0

params and searchParams are promises; fetch and GET Route Handlers are uncached by default.

await params; force-static (or generateStaticParams) to make a GET handler static.

16.0

Synchronous params removed; cacheComponents introduced; Turbopack the default; next build no longer prints sizes.

await params is mandatory; a second model for static pages (shell plus holes); read bundle sizes elsewhere.

16.x (Cache Components)

generateStaticParams must return at least one param; dynamicParams and the dynamic segment config are rejected.

See the Cache Components section above.

16.3

ISR with an App Shell for unlisted params (cacheComponents plus partialPrefetching); next/root-params for reading root parameters in nested generateStaticParams.

The Cache Components counterpart of fallback: true.

Pitfalls

Table 10. Common SSG mistakes, the symptom and the fix
Mistake Symptom Fix

Expecting a static page to be fresh

The content changed in the CMS but the site still shows the old text.

Rebuild, revalidate on demand, or use ISR (the next page of this section). A static page is a snapshot of its build.

new Date(), Math.random() or process.env.X in a static page

One value for everybody, frozen at build time. With Cache Components: build error blocking-prerender-current-time.

Compute it on the client, behind "use cache", or in a dynamic route; read secrets and runtime config on the server per request.

generateStaticParams returns [] with Cache Components

Build error empty-generate-static-params.

Return at least one real sample.

dynamicParams or dynamic = 'force-static' with Cache Components

Build error: segment config is not compatible with cacheComponents.

Remove them.

Unlisted ids with dynamicParams left on

Every unknown id hits your data source; a 404 is not cached.

dynamicParams = false for closed sets, or check existence cheaply first.

An unhandled fallback: true render (Pages Router)

TypeError on book.title: the first render has empty props.

if (router.isFallback) return <Loading />.

Putting secrets or user data into props or Server Component output of a static page

It is in the HTML and the data file, publicly cacheable.

Keep per-user data in dynamic routes or Client Components that call authenticated APIs.

Exporting with a feature a static host cannot serve (proxy.ts, redirects, rewrites, headers, ISR, Server Actions, Draft Mode)

Sometimes a build error, sometimes only a warning, and then silently missing behaviour in production.

Check the table above; drop the feature or deploy a server.

next/image default loader in an export

next dev error; the build succeeds and the HTML points at /_next/image?…​, which 404s on S3.

images.loader: 'custom' or images.unoptimized: true.

Host does not map /books/1 to books/1.html

404 or 403 on every deep link, but the home page works.

A CloudFront Function or nginx try_files, or export with trailingSlash: true and a host that serves index.html in folders.

An SPA-style "send every 404 to `/index.html`" rule

Mistyped URLs show the home page with a 200.

Keep the 404 and serve 404.html.

Dynamic Route Handlers in an export

Files without an extension, wrong content type on S3, broken by the .html rewrite.

Use a fixed file name (books.json) or serve the data from a real API.

Very large generateStaticParams lists

Build time grows with the catalogue and can time out.

Build the top items, leave dynamicParams: true, and cache the rest; tune concurrency.

Where the Books Are Outdated

Book statement or practice Current approach

SSG is getStaticProps and getStaticPaths (both books, Next.js 12 and 13.2).

Both still work in the Pages Router. In the App Router, a prerendered async Server Component plus generateStaticParams replaces them.

Product pages built with getStaticPaths and fallback: 'blocking' (the e-commerce book).

generateStaticParams with the default dynamicParams = true. Under Cache Components, list at least one sample and let the rest render on first request.

A static export is produced with a separate next export step to a CDN.

next export was deprecated in 13.3 and removed in 14; use output: 'export', which next build handles.

next/image works in an export if you pick a loader on the host.

A custom loader or unoptimized: true is mandatory under output: 'export'; the default loader needs a server.

next/head per page for titles and descriptions.

The Metadata API (metadata, generateMetadata), evaluated at build time.

A static page is "SSG", a dynamic one is "SSR", and the choice is yours per page.

The route is static unless it reads request data (previous model), or is split into shell and holes (Cache Components). It is a consequence of the code, not a flag.