Tutorial 10 of 21 · for anyone who'd rather write templates than TSX, without giving up loaders

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:

text
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.ts

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

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:

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:

tera
<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}:

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

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

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

ts
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 }));
tera
<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:

text
there is no notice nope

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

tera
{{ 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.

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

Proudly Created by Excerion Sun LLC