---
title: "Serve Tera templates from Rust"
audience: "Rust developers who want to render FSR routes from Tera templates"
description: "You'll build a notes board that renders from Tera templates, with its plan, loader and action written in Rust. At the end you'll add a note through a form…"
snippet_caption: "templates/notes.tera"
snippet: "<section>\n  <h1>{{ board }}</h1>\n  <ul>{% for note in notes %}<li>{{ note.body }}</li>{% endfor %}</ul>\n  <form method=\"post\" action=\"/_sf/action/add_note\">\n    <input name=\"body\" placeholder=\"a note\" required>\n    <input type=\"hidden\" name=\"_csrf\" value=\"{{ csrf_token | default(value=\"\") }}\">\n    <button>add</button>\n  </form>\n</section>"
---

You'll build a notes board that renders from Tera templates, with its plan, loader and action written in Rust. At the end you'll add a note through a form that works with JavaScript disabled.

This is the only tutorial here that installs neither binary. Everything is a Cargo dependency.

## Start with a config and three templates

```toml title="Cargo.toml"
[package]
name = "notes"
version = "0.1.0"
edition = "2024"

[dependencies]
snapfire_fsr = "0"
snapfire_fsr_core = "0"
snapfire_fsr_host = "0"
snapfire_fsr_runtime = "0"
snapfire_fsr_tera = "0"
tera = { version = "2", features = ["fast"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
parking_lot = "0.12"
```

`config/app.toml`:

```toml title="config/app.toml"
[server]
listen = "127.0.0.1:8099"

[app]
dir = "."

[document]
title = "Notes"

[session]
key = "notes-dev-signing-key-not-a-secret"
csrf = "always"
```

`[session]` is here because the form needs a CSRF token, which is per session.

## Write the templates

`templates/layout.tera` holds the document and one hole:

```jinja title="templates/layout.tera"
<!doctype html>
<html lang="en">
  <head>{{ head() }}</head>
  <body>
    <nav><a href="/notes">notes</a></nav>
    <main>{{ slot(name="content") }}</main>
  </body>
</html>
```

`head()` and `slot(name=...)` are the two functions `snapfire_fsr_tera` registers. `head()` emits a reserved slot the host fills with the title, the meta tags and the stylesheet links it computed before rendering started. `slot(name="content")` is a named hole the plan fills with a child.

`templates/notes.tera` is the page:

```jinja title="templates/notes.tera"
<section>
  <h1>{{ board }}</h1>
  <ul>{% for note in notes %}<li>{{ note.body }}</li>{% endfor %}</ul>
  <form method="post" action="/_sf/action/add_note">
    <input name="body" placeholder="a note" required>
    <input type="hidden" name="_csrf" value="{{ csrf_token | default(value="") }}">
    <button>add</button>
  </form>
</section>
```

`board` and `notes` come from the loader. `csrf_token` arrives without one: the host injects it, along with `params` and `identity`, so `default(value="")` is there for the case where no session carries a token yet.

`templates/error.tera` renders in place of the page when its loader fails:

```jinja title="templates/error.tera"
<section><h1>The board is unavailable</h1><p>{{ error }}</p></section>
```

## Build the plan in Rust

A plan is what the lowered TypeScript would have produced. Here it is written by hand:

```rust title="src/main.rs"
use snapfire_fsr::Plan;

fn notes_plan() -> Plan {
  Plan::of("layout.tera#default").slot(
    "content",
    Plan::of("notes.tera#default").source("notes").error("error.tera#default"),
  )
}
```

`Plan::of` takes a module id, `path#export`. The Tera evaluator looks up the template by path alone and ignores the export, so `#default` is a convention here rather than something it reads. `slot` names the hole in the layout, `source` names the loader and `error` names the template that stands in when that loader fails.

## Register the templates and the evaluator

```rust title="src/main.rs"
fn templates() -> tera::Tera {
  let mut tera = tera::Tera::new();
  snapfire_fsr_tera::register_markers(&mut tera);
  tera
    .add_raw_templates([
      ("layout.tera", include_str!("../templates/layout.tera")),
      ("notes.tera", include_str!("../templates/notes.tera")),
      ("error.tera", include_str!("../templates/error.tera")),
    ])
    .expect("templates parse");
  tera
}
```

> `register_markers` comes first. Tera 2 validates function names when a template is added, so adding a template that calls `slot()` before registering it fails the add.

## Write the program

Here is `src/main.rs`, using `templates()` and `notes_plan()` from above. It compiles and runs.

```rust title="src/main.rs"
use std::path::Path;
use std::sync::Arc;

use parking_lot::Mutex;
use snapfire_fsr::Plan;
use snapfire_fsr_core::{ModuleId, Value, ValueMap};
use snapfire_fsr_host::{Artifact, Config, Host};
use snapfire_fsr_runtime::{ActionError, FailureKind, LoadError};
use snapfire_fsr_tera::TeraEvaluator;

const EMPTY_PLAN: &str = r#"{"version":2,"routes":[]}"#;

#[tokio::main]
async fn main() -> std::io::Result<()> {
  let board: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec!["the first note".to_owned()]));
  let reading = board.clone();
  let writing = board.clone();

  let config = Config::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("config")).map_err(std::io::Error::other)?;
  let host = Host::from_artifact(Artifact { config, plan: EMPTY_PLAN.to_owned(), contract: None })
    .and_then(|builder| {
      builder
        .evaluator(|m: &ModuleId| m.path.ends_with(".tera"), Arc::new(TeraEvaluator::new(templates())))
        .source("notes", move |ctx| {
          let board = reading.clone();
          async move {
            let name = ctx.params.get("board").cloned().unwrap_or_else(|| "general".to_owned());
            if name != "general" {
              return Err(LoadError::new("notes", format!("no board called {name}")).with_kind(FailureKind::NotFound));
            }
            let notes: Vec<Value> = board
              .lock()
              .iter()
              .map(|body| Value::Map([("body".to_owned(), Value::str(body))].into_iter().collect()))
              .collect();
            let mut data = ValueMap::default();
            data.insert("board".to_owned(), Value::str(name));
            data.insert("notes".to_owned(), Value::seq(notes));
            Ok(data)
          }
        })
        .action("add_note", move |_ctx, input| {
          let board = writing.clone();
          async move {
            let Value::Map(fields) = input else {
              return Err(ActionError::new(FailureKind::Invalid, "input must be a map"));
            };
            match fields.get("body") {
              Some(Value::Str(body)) if !body.is_empty() => {
                board.lock().push(body.to_string());
                Ok(Value::Null)
              }
              _ => Err(ActionError::new(FailureKind::Invalid, "`body` must be a non-empty string")),
            }
          }
        })
        .route("/notes", notes_plan())
        .route("/notes/{board}", notes_plan())
        .build()
    })
    .map_err(std::io::Error::other)?;

  print!("{}", host.report());
  let listen = host.listen().to_owned();
  Arc::new(host).serve(&listen).await
}
```

`Host::from` and `Host::from_config` read a plan file from disk and fail when there isn't one. `from_artifact` takes the plan as a string instead, so `EMPTY_PLAN`, an empty route table, is what you hand it when every route is bound in Rust.

In the loader, `ctx.params` holds the route parameters, so one loader serves both routes. `LoadError::new` takes the source id and a message; `with_kind` sets the failure kind, which decides the status. `Value::seq` takes the `Vec<Value>` the template iterates.

## Run it

```shell-session
$ cargo run
routes    /notes                 rust
          /notes/{board}         rust
sources   notes                  rust
actions   add_note               rust
client    /static/js/fsr         15 modules, 87 KiB from the binary
config    config/app.toml
```

Every row says `rust`. The `client` row is the browser half the host serves from its own binary; nothing on these pages loads it.

Open `/notes` and you get the layout, the nav, the title from `[document]` and the list. With `dev` off, the only `<script>` in the response is `<script type="application/json" data-sf-segments>`, which holds data describing the segments and no code. No script runs on the page.

## Add a note with no JavaScript

The form posts to `/_sf/action/add_note` as an ordinary HTML form. The host sees the form encoding, verifies `_csrf` against the session, runs the action, then answers `303 See Other` back to the `Referer`:

```shell-session
$ curl -si -X POST -H 'Referer: http://127.0.0.1:8099/notes' \
    --data 'body=from a form&_csrf=<token from the page>' \
    http://127.0.0.1:8099/_sf/action/add_note | head -2
HTTP/1.1 303 See Other
location: /notes
```

A browser follows that redirect and reloads the board. This is the post-redirect-get pattern, so the back button behaves and a refresh doesn't resubmit. The same action over JSON returns the action's value instead, which is what a client would use; the host picks the form path from the content type and needs no separate registration.

## See the error template

```shell-session
$ curl -s http://127.0.0.1:8099/notes/nope | grep '<h1>'
<h1>The board is unavailable</h1>
```

The loader failed, so `error.tera` rendered in its place and the layout around it is intact: the nav is still there and the document head is still correct.

The response is `404`, because the page's loader failed with `NotFound` and the document takes its status from that. A `LoadError::new` with no kind is `Internal`, which answers `500`. An error template may not call `slot()` or `head()`, since there is no plan child to stitch into it.

## What this shares with the TypeScript path

Everything below the renderer is shared. The loader uses the `source` registration that [500](https://snapfirers.com/fsr/learn/500-move-a-loader-into-rust) uses for an override, the action uses the usual `action` registration and the route uses the usual `route` binding. Services, sessions, the cache from [100](https://snapfirers.com/fsr/learn/100-cache-loaders-and-renders) and the bundle from [410](https://snapfirers.com/fsr/learn/410-ship-it) are the same. The difference is that a module id ending in `.tera` goes to the Tera evaluator rather than to the lowered tree.

That is why the two can be mixed. `evaluator` dispatches on the module id, so a plan can put a Tera layout over a lowered React page and a template can place an island with `island(module="components/Chart.tsx#default", props=chart)` where a component is worth the JavaScript. `examples/advanced_tera_app` does this, with Tera pages, React islands, a deferred segment and a failing backend in one process.

## The evaluator

The evaluator interface is one method that returns a stream of chunks. The Tera evaluator implements it in about a hundred lines. It renders a string with three kinds of marker in it and splits on the markers.

This is the last tutorial. The earlier ones build on each other, so start at [010](https://snapfirers.com/fsr/learn/010-build-your-first-page) if you skipped ahead.
