Tutorial 36 of 36 · for Rust developers who want to render FSR routes from Tera templates · Markdown

Serve Tera templates from Rust

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#

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:

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:

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:

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:

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:

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#

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.

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#

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

$ 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#

$ 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 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 and the bundle from 410 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 if you skipped ahead.