Write your pages in Tera
You'll build a noticeboard whose pages are Tera templates the host renders straight from the file. The routing, the loaders and the prerendering are the same ones every other tutorial uses. What goes away is the TSX: nothing to lower, nothing to bundle, nothing to typecheck for a page.
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.
This is not 520, which embeds Tera in your own Rust binary with no app/ directory and no CLI at all. Here the project is an ordinary FSR app and only the page files change.
Scaffold it
$ fsr new board
$ fsr use board/app tera
tera is a direction like react or vue, so fsr use is how you take it. There's nothing to vendor and no import-map entry: a template runs on the server and ships no code. Add --example and it writes a page.tera beside its loader to start from, which we're replacing anyway.
It needs an fsr built with the tera feature. The published binary is.
A route is still a directory
Put page.tera where page.tsx would go and nothing else about the route changes:
app/
routes/
layout.tera the frame
layout.loader.ts
page.tera /
page.loader.ts
notice/[id]/
page.tera /notice/{id}
page.loader.ts
templates/
nav.tera a partial
src/
notices.tsDelete the scaffold's routes/layout.tsx and routes/page.tsx as you add the .tera files. A directory holding both is refused, naming the two files.
app/src/notices.ts:
export type Notice = { id: string; title: string; who: string; body: string };
export const NOTICES: Notice[] = [
{ id: "bins", title: "Bins go out Tuesday", who: "Ada", body: "Green bin this week, not the blue one." },
{ id: "hall", title: "Hall booked Saturday", who: "Ravi", body: "Back by six if anyone else needs it." },
];The loader is unchanged
This is the part worth internalising. The loader is TypeScript, lowered into the plan and run by the host exactly as it is beside a page.tsx. What it returns is the template's context.
app/routes/page.loader.ts:
import type { Ctx } from "@snapfire/fsr";
import { NOTICES } from "@src/notices";
export async function load(_ctx: Ctx<"/">) {
return { notices: NOTICES.map((n) => ({ id: n.id, title: n.title, who: n.who })) };
}app/routes/page.tera reads that notices the way a page component would have taken it as a prop:
<ul class="notices">
{% for notice in notices %}
<li><a href="/notice/{{ notice.id }}">{{ notice.title }}</a> <span>{{ notice.who }}</span></li>
{% endfor %}
</ul>params, identity and locale reach the template through the loader rather than directly. A template is a rendering of data someone else fetched, which is the same contract a page component has.
The layout places the page
app/routes/layout.tera uses slot where a TSX layout writes {children}:
<div class="board">
<header>{% include "templates/nav.tera" %}</header>
<main>{{ slot(name="content") }}</main>
<footer>{{ count }} notices, rendered from templates under <code>routes/</code>.</footer>
</div>with its own loader beside it, independent of the page's:
import type { Ctx } from "@snapfire/fsr";
import { NOTICES } from "@src/notices";
export async function load(_ctx: Ctx) {
return { count: NOTICES.length };
}app/templates/nav.tera is a partial and can sit anywhere outside vendor/, dist/ and generated/:
<a href="/">Noticeboard</a>Every .tera under app/ is loaded into one Tera instance and named by its path under the app, which is why the include says templates/nav.tera. extends resolves the same way.
The parameterised route
Same shape, plus a paths export so 110's prerendering applies here too:
import type { Ctx } from "@snapfire/fsr";
import { fail } from "@snapfire/fsr";
import { NOTICES } from "@src/notices";
export async function load({ params }: Ctx<"/notice/{id}">) {
const notice = NOTICES.find((n) => n.id === params.id);
if (!notice) fail("not_found", `there is no notice ${params.id}`);
return { title: notice.title, who: notice.who, body: notice.body };
}
export const paths = () => NOTICES.map((notice) => ({ id: notice.id }));<article>
<h1>{{ title }}</h1>
<p>{{ body }}</p>
<p>Posted by {{ who }}.</p>
</article>Build it
$ fsr build board/app
routes / routes
/notice/{id} routes/notice/[id]
layouts / routes/layout.tera#default
sources layout lowered routes/layout.loader.ts
$root lowered routes/page.loader.ts
notice.$id lowered routes/notice/[id]/page.loader.ts
rendered routes/error.tsx#default lowered static
routes/layout.tera#default template
routes/not-found.tsx#default lowered static
routes/notice/[id]/page.tera#default template
routes/page.tera#default template
template is the whole difference. A TSX page says lowered static, meaning the build turned it into a tree the host walks; a .tera page says template, meaning the host reads the file. The three loaders still say lowered, because they are.
The .tera files are listed among tsconfig.build.json's includes and snapfirec compiles nothing from them. error.tsx and not-found.tsx are still TSX here, since the scaffold wrote them; they'd take templates too.
Run it
$ fsr serve board/app
$ curl -s localhost:3000/
<div class="board">
<header><a href="/">Noticeboard</a>
</header>
<main><!--sf-g:routes/page.tera#default--><ul class="notices">
<li><a href="/notice/bins">Bins go out Tuesday</a> <span>Ada</span></li>
<li><a href="/notice/hall">Hall booked Saturday</a> <span>Ravi</span></li>
</ul>
<!--/sf-g--></main>
<footer>2 notices, rendered from templates under <code>routes/</code>.</footer>
</div>
The <!--sf-g:...--> comments are the segment markers the navigator uses, the same ones a TSX page gets. Everything between them is your template's own output.
Note 2 notices in the footer. That's the layout's loader, evaluated independently of the page's, which is the layout/page split behaving exactly as it does everywhere else.
Prerendering works here too
Nothing about paths cares that the page is a template:
$ fsr prerender board/app
loads.json board/app/dist/prerender/loads.json
/ board/app/dist/prerender/index.html
/ board/app/dist/prerender/index.payload
/notice/bins board/app/dist/prerender/notice/bins/index.html
/notice/bins board/app/dist/prerender/notice/bins/index.payload
/notice/hall board/app/dist/prerender/notice/hall/index.html
/notice/hall board/app/dist/prerender/notice/hall/index.payload
prerendered.json board/app/dist/prerender/prerendered.json
Point [server] prerender at dist/prerender and a notice in the set is answered from a file:
$ curl -s -o /dev/null -D - localhost:3000/notice/bins
HTTP/1.1 200 OK
x-sf-prerendered: 1
$ curl -s -o /dev/null -D - localhost:3000/notice/nope
HTTP/1.1 404 Not Found
The miss ran the loader live, raised fail("not_found", ...) and answered 404 with the message:
there is no notice nopeTwo things that will catch you
Templates are read at boot. Edit a .tera and the running server keeps serving the old one, because the host loaded them all at startup. Restart it. Under fsr dev the loop does that for you.
That also means a renamed partial is a boot error rather than an empty page: the host refuses a plan naming a template the tree doesn't hold.
An island still works. A template places one with a literal:
{{ island(module="src/ui/Thing.tsx#default") }}The build reads the literal and bundles that module, so you can hydrate one corner of a page nothing else in the application hydrates. It has to be a literal, since the build can only bundle a module it can name.
The board above has no framework, so placing a React component there stops the build on the adapter check rather than on anything to do with templates:
$ fsr build board/app
`src/ui/Thing.tsx#default` mounts through `@snapfire/fsr-client/react`, but the import map does not name `@snapfire/fsr-client/react`, `react` or `react-dom/client`; `fsr use <app dir> react` writes it
Run fsr use board/app react first if you want one.
The lab
Put a page.tsx next to one of the page.tera files and build:
$ fsr build board/app
board/app/routes: holds both `page.tsx` and `page.tera`; a directory has one page file and one layout file
Rename templates/nav.tera and start the server. It refuses at boot, with Tera's own error and the line of the include:
$ fsr serve board/app
templates under board/app: error: Unknown template `templates/nav.tera`
2 | <header>{% include "templates/nav.tera" %}</header>
That's the failure you want, rather than a page that renders with a hole in it.
Take paths out of the notice loader and prerender again: the two notice files stop being written and the route drops off the boot report's prerender list. Every notice now renders per request, which for two notices costs nothing and for two thousand is the reason paths exists.