Tutorial 5 of 54 · for anyone with pages that don't change between deploys · Markdown

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. A post that isn't in the set still renders live, so a new post is served before the next prerender picks it up.

Before you start

Install from crates.io. You need no Node and 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.

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:

$ fsr new blog --with react

app/src/posts.ts:

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:

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:

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:

config/app.toml
[server]
prerender = "dist/prerender"

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

$ 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#

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

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

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

$ 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.