Skip to content

Static generation

Rendering pages ahead of time, and exporting a site of files.

Updated View as Markdown

Pages that do not need the request can be rendered once, at build time, and served from disk — no render per request, no round trip to a database.

The build decides by rendering

Nothing declares whether a route is stored. The build renders each one with a budget and watches what happens:

Outcome What the probe saw
The whole page It rendered to completion. Stored and served from disk.
A shell Something was still waiting when the budget expired, but the static parts had painted. The shell is stored; the rest is rendered per request.
A redirect The route only redirects, so the redirect itself is stored — status and location.
Refused Nothing painted before the page blocked. The build fails.

The budget is 2 seconds, and RSC_PPR_TIMEOUT_MS changes it. A page whose data resolves inside it is stored whole — including one that called fetch(), as long as the answer arrived.

There is no way to opt out, and that is the point: a route the build cannot store has its boundary in the wrong place. Put the part that waits inside <Suspense>, or add a loading.tsx beside the page, and it becomes a shell.

Listing the URLs of a parameterised route

A route with a [param] cannot be frozen unless something says which values exist. Export generateStaticParams from the page:

src/app/direct/[slug]/page.tsxtsx
import { Suspense } from 'react'
import { allSlugs, findPost, SECRET } from '../../../data'
import type { Metadata } from '@rsc-kit/core/metadata'

// Which urls exist. The one thing the build cannot work out for itself — and
// the reason this route is frozen per url, while /posts/[slug], which declares
// nothing, is frozen once as a shell.
export function generateStaticParams() {
  return allSlugs().map((slug) => ({ slug }))
}

export const metadata: Metadata = { title: 'Direct import' }

async function Body({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await findPost(slug)

  // Referenced so the bundler cannot tree-shake the module away — the point is
  // that it is in the server graph and not the client one.
  const proof = SECRET.length

  if (!post) return <h1>No such post: {slug}</h1>

  return (
    <>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <p className="muted">secret length on the server: {proof}</p>
    </>
  )
}

export default function DirectPage({ params }: { params: Promise<{ slug: string }> }) {
  return (
    <Suspense fallback={<p className="muted">Loading…</p>}>
      <Body params={params} />
    </Suspense>
  )
}

Each object it returns is one URL to build. For a route with several params, return every combination:

src/app/blog/[year]/[slug]/page.tsxtsx
export function generateStaticParams() {
  return [
    { year: '2025', slug: 'hello-world' },
    { year: '2025', slug: 'getting-started' },
  ];
}

A route that lists nothing is rendered on demand. That is a decision, not a failure — /posts/[slug] in the example app declares no params precisely so the two paths can be compared side by side.

Running the prerender

vite build does it, at the end, once every bundle exists — prerendering is the app rendering itself, so it needs the thing the build just produced.

bun run build
#  ○  /
#  ○  /about
#  ◔  /account
#
#  2 stored, 1 shell

Turning it off

vite.config.tsts
rscKit({ sourceDir: 'src', prerender: false });

Try connection() first. It is almost always the better answer, and this is almost always too big a hammer.

It is also one hammer for two things. The decision that a page has nothing to hydrate is made in the same build-time render, so with prerendering off every page renders per request, with the runtime. That is the right trade for a build machine that cannot reach the data — the reason this exists — and the wrong one for anything else.

Prerendering runs your application code, so it needs whatever that code needs — a page that queries a database needs that database reachable from the build. When it is not, marking that one query is a smaller and more accurate statement than switching prerendering off for every route in the app:

await connection()

const rows = await db.query('select * from orders')

The build then skips that work, stores everything around it, and the page still gets a shell. Turning prerendering off stores nothing, anywhere, and every page renders for every visitor forever.

Reach for this switch when the reason is about the build itself rather than any page:

Two other reasons: a large site adds real time to every build, and a deploy that has to run migrations first may want to prerender at a later stage entirely. RSC_PRERENDER=0 does the same thing for a host that drives the build out of process and cannot pass an option.

It is off in watch mode regardless — a rebuild on every keystroke that also re-renders every route is not a feedback loop anyone wants.

Doing it yourself

prerender is also a plain function, exported from @rsc-kit/core/prerender, so it runs wherever your build does. write is a callback rather than a directory, because not every place this runs has a filesystem — writeTo is the node:fs implementation of it, and a platform without one passes its own.

You should not need it. The build calls it for you, with the engine bundle it just produced — a path only the build knows, since Nitro builds the rsc environment under node_modules.

What gets written

Per route, keyed by its URL — / becomes index, /docs/install becomes docs/install:

File For
{key}.html A full page load.
{key}.flight An SPA navigation that replaces the whole document.
{key}.seg1.flight, {key}.seg2.flight, … An SPA navigation that keeps that many layouts.
{key}.meta.json The client chunks and build version the payload belongs to.
{key}.ppr.html A shell, stored per route pattern rather than per URL.

The .seg{n} variants are what make a prerendered route participate in partial navigation. Without them every arrival at a frozen page would replace the document root, unmounting the pages retained behind it — the form you were filling in would not survive going back to it.

Serving them

The host checks for a frozen file before it matches a route. prerendered is the source it checks, and like write it is a callback:

The generated server does this for you: the build freezes pages into .output/server/rsc-static, and the entry reads them from beside itself. Nothing found, and the request falls through to a live render.

A value that must not be frozen

A stored page keeps whatever it rendered, which includes whatever the clock said at build time. So this renders once, during the build, and serves that same instant to everyone until the next one:

export default function Page() {
  // Frozen. Not "a few seconds stale" — the moment your CI ran.
  return <p>Rendered at {new Date().toISOString()}</p>;
}

The build says so rather than leaving you to notice:

○  /
   ⚠  froze new Date() — a stored page keeps whatever that returned at build
      time. If it should differ per visitor, await connection() so the page
      renders per request; if only the browser needs it, use(browser()) keeps
      it out of the build entirely.

new Date(), Date.now(), Math.random() and crypto.randomUUID() are all watched.

Moving it into a client component makes it worse, not better. Hydration is not React attaching handlers to existing markup — it runs the component, because that is the only way it learns what the tree should be.

So the body executes twice, once where the HTML came from and once in the browser, and a clock read inside gives two different answers.

Where the value comes from is what decides it, not whether the component is a client one:

the value is result
read in a server component frozen at build time, and the build warns
read in a client component’s body produced twice, and the two disagree
computed on the server, passed as a prop travels in the payload, so both renders read the same string
read only in the browser nothing on the server to disagree with

The third row is the ordinary answer for anything the server can decide. It does hydrate the client component, and there is no mismatch, because the value arrived rather than being recomputed. It is still frozen with the page, which is fine for a build stamp and wrong for a clock.

Rendering something in the browser only

For a value that is genuinely per-visitor and needs no server — a clock, localStorage, a map, an editor — say so, and React will skip the component on the server:

'use client';

import { Suspense, use, useState } from 'react';
import { browser } from 'react-dom';

function SavedDraft() {
  use(browser('the draft lives in localStorage'));

  const [draft] = useState(() => localStorage.getItem('draft') ?? '');

  return <p>{draft || 'Nothing saved yet'}</p>;
}

The page it sits on stays frozen at build time. React stops the server render at that component and puts the nearest Suspense boundary’s fallback into the HTML; everything around it rendered normally, so there is still a finished document to store.

<Suspense fallback={<p>Loading draft…</p>}>
  <SavedDraft />
</Suspense>

That fallback is what visitors see in the first paint, and the real thing replaces it once the browser renders. There is no mismatch to have, because only one side ever ran.

Three things to know:

  • The Suspense boundary is required. Without one, the server render fails rather than degrading.
  • It has to be a client component. browser() is about skipping the server render of something that will render in the browser; a server component has no browser render to fall back to.
  • browser() alone does nothing. Pass it to use(). Do not throw it.

The reason is optional and only ever read on the server, where it shows up in the renderer’s bailout callback. Give it one anyway — it is the sentence the next person needs. Pass a function if building it is expensive.

Exporting a site

Build with output: 'export' and the build writes a directory a static host can serve with no origin at all:

RSC_OUTPUT=export npm run build
# Exported 9 pages to dist

It refuses unless every route came out static. A shell on a static host is a page that loads and then stays empty forever, because there is nothing running to fill it in. RSC_EXPORT_FORCE=1 writes the site anyway and reports what it left out, which is how you move an app towards being exportable.

A route that only redirects is written as a meta refresh, which is the one redirect every static host performs without being configured.

Customising the document

There is no separate shell template. The root layout.tsx renders the whole document, and the build injects the bootstrap script and stylesheet links into it. The same layout serves streamed and frozen pages.

src/app/layout.tsxtsx
import './styles.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body>{children}</body>
    </html>
  );
}

Per-page titles and meta tags come from the page, not the layout — see Page metadata.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close