---
title: "Write your pages in Tera"
audience: "anyone who'd rather write templates than TSX and still wants loaders"
description: "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…"
snippet_caption: "app/routes/page.tera"
snippet: "<ul class=\"notices\">\n  {% for notice in notices %}\n    <li><a href=\"/notice/{{ notice.id }}\">{{ notice.title }}</a> <span>{{ notice.who }}</span></li>\n  {% endfor %}\n</ul>"
---

import { Install } from "@src/ui/Install";

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.

<Install captured="0.x" />

[520](https://snapfirers.com/fsr/learn/520-serve-tera-templates-from-rust) 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

```shell-session
$ 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`:

```ts title="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`:

```ts title="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:

```tera title="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}`:

```tera title="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:

```ts title="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/`:

```tera title="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](https://snapfirers.com/fsr/learn/110-prerender-a-blog-at-build-time)'s prerendering applies here too:

```ts title="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 }));
```

```tera title="app/routes/notice/[id]/page.tera"
<article>
  <h1>{{ title }}</h1>
  <p>{{ body }}</p>
  <p>Posted by {{ who }}.</p>
</article>
```

## Build it

```shell-session
$ 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

```shell-session
$ 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:

```shell-session
$ 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`:

```toml title="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`:

```shell-session
$ 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:

```shell-session
$ 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:

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

```shell-session
$ 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:

```shell-session
$ 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:

```shell-session
$ 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.
