Prerender a blog at build time
You'll build a three-post blog where every post is a file on disk before the first request arrives. There's no cache to warm and no revalidation window to tune. A post that isn't in the set still renders live, so nothing breaks while you're adding one.
Before you start
Straight from crates.io. No Node, no package manager.
cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version
Every command and screenshot on this page was captured with fsr 0.x.
The rule you already have
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, so there's nothing to declare. fsr prerender writes those routes to files and the boot report lists them.
A route with a parameter breaks that rule for a boring reason. /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 is the right default and the wrong answer for a blog.
Say what the slugs are
Start an app and give it some posts:
$ fsr new blog --with react
app/src/posts.ts:
export type Post = { slug: string; title: string; date: string; body: string };
export const POSTS: Post[] = [
{ slug: "why-no-node", title: "Why there is no Node here", date: "2026-01-14", body: "The server never starts a JavaScript engine." },
{ 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 the loader you'd write anyway; the new export is the last line:
import type { Ctx } from "@snapfire/fsr";
import { fail } from "@snapfire/fsr";
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 is a page:
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:
[server]
prerender = "dist/prerender"That path is relative to the app directory, not the project root. Then:
$ fsr build blog/app
$ fsr prerender blog/app
loads.json blog/app/dist/prerender/loads.json
/ blog/app/dist/prerender/index.html
/ blog/app/dist/prerender/index.payload
/post/why-no-node blog/app/dist/prerender/post/why-no-node/index.html
/post/why-no-node blog/app/dist/prerender/post/why-no-node/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
prerendered.json blog/app/dist/prerender/prerendered.json
Two files per path. index.html is the document a cold visitor gets. index.payload is what a client navigation asks for, so clicking a post from the index is also served off disk rather than rendered.
prerendered.json is the list of what this run wrote:
[
"loads.json",
"index.html",
"index.payload",
"post/why-no-node/index.html",
"post/why-no-node/index.payload",
"post/the-plan-file/index.html",
"post/the-plan-file/index.payload",
"post/islands-cost/index.html",
"post/islands-cost/index.payload"
]The next run deletes everything in that list before it writes again. Delete a post from POSTS and its file goes away, which is the part a hand-rolled export script always gets wrong.
What the host says at boot
$ fsr serve blog/app
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
$ 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 no loader ran and no plan was walked. The host read a file and wrote it back.
Now ask for one that isn't there:
$ 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 a crawler and a cache both get told the truth. The message went through as well, interpolated slug and all:
there is no post nopeThat's worth knowing on its own. 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.
Why this is safe to leave on
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. Nothing 404s because the build was stale.
The shape that gets you in trouble is a loader that reads the clock. now is a request read like any other, so the route drops off the prerender list at the next build whatever paths says. If a post page shows "posted 3 days ago", that's the reason.
The lab
Take paths out and build again. The /post/{slug} row leaves the boot report's prerender list altogether, not just its per paths note. curl -i then shows no x-sf-prerendered on any post. Put it 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 says which slugs exist; it does not promise the loader ignores everything else.