---
title: "Prerender a blog at build time"
audience: "anyone with pages that don't change between deploys"
description: "You'll build a three-post blog where every post is a file on disk before the first request arrives. A post that isn't in the set still renders live, so a new…"
snippet_caption: "app/routes/post/[slug]/page.loader.ts"
snippet: "import type { Ctx } from \"@snapfire/fsr/host\";\nimport { fail } from \"@snapfire/fsr/host\";\nimport { POSTS } from \"@src/posts\";\n\nexport async function load({ params }: Ctx<\"/post/{slug}\">) {\n  const post = POSTS.find((p) => p.slug === params.slug);\n  if (!post) fail(\"not_found\", `there is no post ${params.slug}`);\n  return { title: post.title, date: post.date, body: post.body };\n}\n\nexport const paths = () => POSTS.map((post) => ({ slug: post.slug }));"
---

import { Install } from "@src/ui/Install";

You'll build a three-post blog where every post is a file on disk before the first request arrives. A post that isn't in the set still renders live, so a new post is served before the next prerender picks it up.

<Install captured="0.x" />

## Which routes prerender on their own

A route renders once for everyone when its loader reads nothing about the request: no query, no session, no identity, no clock. The host works that out from the lowered loader. `fsr prerender` writes those routes to files and the boot report lists them.

A route with a parameter doesn't qualify. `/post/{slug}` renders the same for every request *given a slug*, but nothing on the server knows which slugs exist. So it renders per request, which suits most routes but not a blog.

## Say what the slugs are

Start an app and give it some posts:

```shell-session
$ fsr new blog --with react
```

`app/src/posts.ts`:

```ts title="app/src/posts.ts"
export type Post = { slug: string; title: string; date: string; body: string };

export const POSTS: Post[] = [
  { slug: "one-binary", title: "One binary serves the whole app", date: "2026-01-14", body: "The server renders every page itself." },
  { slug: "the-plan-file", title: "Reading the plan file", date: "2026-02-02", body: "It is data, so you can diff it." },
  { slug: "islands-cost", title: "What an island actually costs", date: "2026-03-19", body: "One mounter, one module, one round trip." },
];
```

Now the post route. The loader is an ordinary one and the new export is `paths` on the last line:

```ts title="app/routes/post/[slug]/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";
import { fail } from "@snapfire/fsr/host";
import { POSTS } from "@src/posts";

export async function load({ params }: Ctx<"/post/{slug}">) {
  const post = POSTS.find((p) => p.slug === params.slug);
  if (!post) fail("not_found", `there is no post ${params.slug}`);
  return { title: post.title, date: post.date, body: post.body };
}

export const paths = () => POSTS.map((post) => ({ slug: post.slug }));
```

`paths` returns one object per path, with a key per parameter in the pattern. It runs at build rather than per request, so it may call a service, which is what you want when the slugs come out of a CMS instead of a constant. It may not read the request; the build refuses it if it tries.

The page itself is ordinary:

```tsx title="app/routes/post/[slug]/page.tsx"
import type { PostSlugProps } from "@generated/client";

export default function Post({ title, date, body }: PostSlugProps) {
  return (
    <article>
      <h1>{title}</h1>
      <time>{date}</time>
      <p>{body}</p>
    </article>
  );
}
```

## Write the files

Point the config at a directory first, in `config/app.toml`:

```toml title="config/app.toml" {2}
[server]
prerender = "dist/prerender"
```

That path is relative to the app directory, not the project root. Then build and prerender:

```shell-session
$ fsr build blog/app
$ fsr prerender blog/app | grep -v static/js/fsr/
loads.json             blog/app/dist/prerender/loads.json
/                      blog/app/dist/prerender/index.html
/                      blog/app/dist/prerender/index.payload
/post/one-binary       blog/app/dist/prerender/post/one-binary/index.html
/post/one-binary       blog/app/dist/prerender/post/one-binary/index.payload
/post/the-plan-file    blog/app/dist/prerender/post/the-plan-file/index.html
/post/the-plan-file    blog/app/dist/prerender/post/the-plan-file/index.payload
/post/islands-cost     blog/app/dist/prerender/post/islands-cost/index.html
/post/islands-cost     blog/app/dist/prerender/post/islands-cost/index.payload
prerender.build        blog/app/dist/prerender/prerender.build
prerendered.json       blog/app/dist/prerender/prerendered.json
```

The `grep` hides one row per fsr client module, which the run also writes under `static/js/fsr/` so the directory holds everything the pages load.

Each path gets two files. `index.html` is the document a cold visitor gets. `index.payload` is what a client navigation asks for, so clicking through to a post from the index is also served from disk. `prerender.build` records which build the files were rendered from. A host built from different sources doesn't read them, so a stale prerender is never served after a deploy that forgot to run this step.

`prerendered.json` is the list of what this run wrote, the pages and the client modules. The next run deletes everything in that list before it writes again. Delete a post from `POSTS` and the next run removes its file.

## Read the boot report

```shell-session
$ fsr serve blog/app --verbose
prerender /                      <app>/dist/prerender
          /post/{slug}           <app>/dist/prerender per paths
```

`per paths` is how you tell the two cases apart. The index qualified on its own because its loader reads nothing. The post route qualified because you said what the slugs are.

## Check it

`fsr serve` reads `config/development.toml`, which turns `dev` on. In development every page renders live, so an edit shows on reload rather than waiting for the next prerender. Ask for the prerendered version with `?__prerendered`:

```shell-session
$ curl -s -o /dev/null -D - 'http://127.0.0.1:3000/post/the-plan-file?__prerendered'
HTTP/1.1 200 OK
x-sf-prerendered: preview
```

`preview` means the host rendered the page the way `fsr prerender` does, anonymously and with the head a written file carries, without reading the directory. Without the query the same request has no `x-sf-prerendered` header at all.

In production the files are what answers. Stop the server and start it the way a deployment does, with `RELEASE_ENV=production` and a session key, since `config/production.toml` refuses its placeholder:

```shell-session
$ RELEASE_ENV=production C5_SESSION__KEY=$(openssl rand -hex 32) fsr serve blog/app
$ curl -s -o /dev/null -D - http://127.0.0.1:3000/post/the-plan-file
HTTP/1.1 200 OK
x-sf-prerendered: 1
```

`x-sf-prerendered: 1` means the host answered from the file without running a loader or walking the plan.

Now ask for one that isn't there:

```shell-session
$ curl -s -o /dev/null -D - http://127.0.0.1:3000/post/nope
HTTP/1.1 404 Not Found
```

No `x-sf-prerendered` header, because that one was rendered live: the loader ran, found nothing and raised `fail("not_found", ...)`. The status follows the failure, so crawlers and caches see a real 404. The message comes through too, with the slug interpolated:

```
there is no post nope
```

The kind is a string literal because the build matches on it, but the message is any expression and it's evaluated only if the guard actually fires.

## Posts added between deploys

The set only decides which paths get a file. A path in it is served from that file; a path outside it renders the way it always did. So a post added to the database between deploys is live immediately, just without the file, until the next `fsr prerender` picks it up.

> Reading the clock also stops a route prerendering. `now` is a request read like any other, so the route drops off the `prerender` list at the next build whatever `paths` says. A post page that shows "posted 3 days ago" reads the clock, so it won't prerender.

## Try it

Take `paths` out and build again. The `/post/{slug}` row leaves the boot report's `prerender` list altogether, not just its `per paths` note. The `/` row says its files were written by another build until you prerender again. A post asked for with `?__prerendered` now answers 404, since there's no prerendered version of it to preview. Put `paths` back.

Then delete the middle post from `POSTS` and run `fsr prerender` a second time. Read `prerendered.json` before and after: the two `post/the-plan-file/` files are gone from the directory, not just from the list.

Last, leave `paths` in place and make the post loader read `query` as well. The route still drops off the list, because a query is part of the request and no file answers every query. `paths` lists the slugs. It doesn't exempt the route from any other request read.
