Tutorial 5 of 21 · for anyone with pages that hold still between deploys

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:

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:

ts
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:

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
[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:

json
[
  "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:

text
there is no post nope

That'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.

Built with SnapFire FSR. Pure Rust runtime, zero Node.js on the server.

Proudly Created by Excerion Sun LLC