Render with Tera and no TypeScript
You'll build a notes board that renders from Tera templates. No app/ directory, no .tsx, no plan file and neither the fsr CLI nor snapfirec at any point. 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
[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:
[server]
listen = "127.0.0.1:8099"
[app]
dir = "."
[document]
title = "Notes"
[session]
key = "notes-dev-signing-key-not-a-secret"
csrf = "always"Note what isn't there. No [document] entry, because there is no browser bundle to name. No [document] import_map, because nothing imports anything. No [[static]] for JavaScript. [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:
<!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:
<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:
<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. Writing it by hand is four lines:
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
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.
The whole program
src/main.rs, with templates() and notes_plan() from above. This compiles and runs.
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::{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_config_with(config, EMPTY_PLAN.to_owned(), 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 { source_id: "notes".to_owned(), message: format!("no board called {name}") });
}
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
}EMPTY_PLAN is the one piece that needs explaining. Host::from and Host::from_config read a plan file from disk and fail when there isn't one. from_config_with takes the plan as a string instead, so an empty route table is what you hand it when every route is bound in Rust.
Three things about the loader. ctx.params is the route parameters, so one loader serves both routes. A LoadError is a struct with two fields rather than something with a constructor. And 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 and it costs nothing until something does.
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 is data describing the segments rather than code. Nothing executes.
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 form path is chosen by the content type, not by a 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. One segment degraded rather than the page.
Two honest notes on that. The response is still 200, because a failed loader is not by itself a missing route, so if you want a 404 you return one from a handler rather than from a loader. And 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. The loader is the same source registration 500 uses for an override. The action is the same action registration. The route is the same route binding. Services, sessions, the cache from 100 and the bundle from 410 are the same. What changed is one predicate: a module id ending in .tera goes to the Tera evaluator rather than to the lowered tree.
Which is why the two mix. 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 is that app: Tera pages, React islands, a deferred segment and a failing backend, all in one process.
The seam this fills
The seam this uses is one method returning a stream of chunks. It assumes no component tree, no hydration boundary, no virtual DOM and no JavaScript engine. Tera fills it in about a hundred lines: render a string, leave three kinds of marker in it, split on the marker. Anything that can produce a string can do the same.
That's the last one. Go back to 010 if you skipped here, since the earlier ones build on each other.