@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
357 lines (274 loc) • 13.9 kB
Markdown
# Static generation
> Rendering pages ahead of time, and exporting a site of files.
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.
Waiting does not hold up the build. A page that has rendered what it can and is
only waiting steps aside for the next one, so hundreds of shells cost about one
budget between them, not one each.
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.
<Aside type="caution" title="Stored means stored">
Whatever a page read at build time is in the file. If it should reflect the
current state of the world on every request, it needs to read the request —
`params`, `searchParams`, `headers()`, `cookies()` — which suspends, and puts
it behind a boundary rather than in the stored bytes.
</Aside>
## 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:
```tsx title="src/app/direct/[slug]/page.tsx"
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:
```tsx title="src/app/blog/[year]/[slug]/page.tsx"
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.
```bash
bun run build
# ○ /
# ○ /about
# ◔ /account
#
# 2 stored, 1 shell
```
### Opting a page out
There is no switch that turns prerendering off for the app. A page that must
not be stored says so, in the page, with [`connection()`](/guides/connection):
```tsx
await connection()
const rows = await db.query('select * from orders')
```
The build skips that read, stores everything around it, and the page still
gets a shell. This is the same model Next has with cache components, where
the only opt-out is per page — and for the same reason: the page that
depends on the request is the one that knows.
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 — or `await connection()` before the query, which is the smaller and
more accurate statement. When a page cannot render at build time the build
refuses and names it, so nothing is silently rendered live that could have
been stored.
**All of it per request** is the root layout reading the request —
`await connection()` there — with a root `loading.tsx` to give every page a
shell. That is what Next's `force-dynamic` on the root layout does, and it
costs what it costs: every page is a stored fallback with the rest streamed,
and every page has the runtime. Measured in the repository's `bench/`, a
page that could have been stored renders five times slower this way, which
is why there is no switch to make it the default.
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 `-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:
```tsx
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.
<Aside type="caution" title="Suspense alone does not fix this">
Wrapping it in a boundary changes nothing: prerendering renders straight
through a component that never awaits, so the value is captured exactly as
before — same `○`, same warning. A boundary becomes a hole only when
something inside it *waits* for what the build cannot finish — a read that
outlasts the build's budget, or a page that has said `await connection()`.
</Aside>
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:
```tsx
'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.
```tsx
<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.
<Aside type="note" title="This replaced a workaround">
The old answer was an empty first render plus a `useEffect`, which worked by
making both renders agree on nothing. `browser()` lands the same outcome with
the fallback in the HTML instead of a blank, and says in the component why.
It is in `react-dom` 19.3.
This package ships no `ClientOnly` of its own, and now never will — the
boundary was always React's to draw.
</Aside>
## Exporting a site
Build with `output: 'export'` and the build writes a directory a static host
can serve with no origin at all:
```bash
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.
Everything a server would have answered as a file comes along: the browser
bundle, your `public/` folder, the icons and share image beside the root
layout, the web manifest and the service worker. So do stored `route.ts`
answers - `robots.txt`, `sitemap.xml`, a JSON feed - written as the files they
are. A static host types a file by its extension, so only an answer at a url
with one can keep its content type: `/api/health` would be served without its
`application/json`, and the build lists it as left out rather than exporting
it wrong. Give such a route an extension (`/api/health.json`), or serve it
from a host that runs.
## 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.
```tsx title="src/app/layout.tsx"
import './styles.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
```
<Aside type="tip" title="Fonts and global CSS belong in the root layout">
The root layout renders once and survives every SPA navigation, so its
`<link>` tags load a single time. The same tags in a nested layout or a page
are re-injected on every navigation.
</Aside>
Per-page titles and meta tags come from the page, not the layout — see
[Page metadata](/guides/metadata).