Tutorial 16 of 54 · for anyone who'd rather write templates than TSX and still wants loaders · Markdown

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. Only the page files change, from TSX to Tera, while the build still lowers the loaders.

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.

520 is a different setup: it embeds Tera in your own Rust binary with no app/ directory and no CLI. Here the project is an ordinary FSR app and only the page files change.

Scaffold it#

$ fsr new board
$ fsr use board/app tera
types     @snapfire/fsr/authoring      fsr 0.21.0
types     @snapfire/fsr/client         fsr 0.21.0
types     @snapfire/fsr/desktop        fsr 0.21.0
wrote     board/app/generated/plan.sexp
...

tera is a direction like react or vue, so fsr use is how you take it. A template runs on the server and ships no code to the browser. Add --example and it writes a page.tera beside its loader to start from, which this tutorial replaces.

It needs an fsr built with the tera feature, which the published binary is.

Lay out the routes#

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

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." },
];

Write the loader#

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:

app/routes/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";
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:

app/routes/page.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 only renders data the loader fetched, the same as a page component.

Write the layout#

app/routes/layout.tera uses slot where a TSX layout writes {children}:

app/routes/layout.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:

app/routes/layout.loader.ts
import type { Ctx } from "@snapfire/fsr/host";
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/:

app/templates/nav.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.

Add a parameterised route#

It has the same shape, plus a paths export so 110's prerendering applies here too:

app/routes/notice/[id]/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";
import { fail } from "@snapfire/fsr/host";
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 }));
app/routes/notice/[id]/page.tera
<article>
  <h1>{{ title }}</h1>
  <p>{{ body }}</p>
  <p>Posted by {{ who }}.</p>
</article>

Build it#

$ fsr build board/app --verbose
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
hoisted   routes/error.tsx#default           1 subtree
          routes/not-found.tsx#default       1 subtree
types     @snapfire/fsr/authoring types/@snapfire/fsr/authoring  fsr 0.21.0
          @snapfire/fsr/client   types/@snapfire/fsr/client  fsr 0.21.0
          @snapfire/fsr/desktop  types/@snapfire/fsr/desktop  fsr 0.21.0
plan      generated/plan.sexp    4.1 KB written
typecheck tsc 7.0.2 from cache, clean

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 are still lowered.

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, whitespace included: the blank lines are where the {% for %} and {% endfor %} tags stood. Write {%- for notice in notices %} and {%- endfor %} if you want Tera to trim them.

2 notices in the footer comes from the layout's loader, which runs independently of the page's, as it does with TSX.

Prerender the notices#

paths works the same with 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
/static/js/fsr/actions.js board/app/dist/prerender/static/js/fsr/actions.js
...
/static/js/fsr/vue.js  board/app/dist/prerender/static/js/fsr/vue.js
prerender.build        board/app/dist/prerender/prerender.build
prerendered.json       board/app/dist/prerender/prerendered.json

The static/js/fsr/ files are the client modules, so the directory is a site anything can serve. prerender.build is the hash of the build it rendered from. A host of a different build ignores the directory.

Point [server] prerender at the directory in config/app.toml:

config/app.toml
[server]
listen = "127.0.0.1:3000"
prerender = "dist/prerender"

A development host renders every page live, so build, prerender and serve under RELEASE_ENV=production. The production layer refuses the placeholder session key, so give it one in C5_SESSION__KEY:

$ export RELEASE_ENV=production C5_SESSION__KEY=$(openssl rand -hex 32)
$ fsr build board/app
$ fsr prerender board/app
$ fsr serve board/app

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 nope

Boot-time templates and islands#

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 `react` or `react-dom/client`; `fsr use <app dir> react` writes it

Run fsr use board/app react first if you want one.

Try it#

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`
 --> routes/layout.tera:2:22
  |
2 |   <header>{% include "templates/nav.tera" %}</header>
  |                      ^^^^^^^^^^^^^^^^^^^^

Take paths out of the notice loader and prerender again: the two notice files stop being written and the route drops off the prerender list fsr serve --verbose prints. Every notice now renders per request. With two notices that costs nothing, but with thousands you want paths back.