SnapFire FSR Tutorials
In each tutorial you install two crates, run fsr new and build what the title describes.
Start here
- 010Build your first pageYou'll scaffold an app, change its loader to return some data and put that data on a page. A Rust binary serves it.app/routes/page.loader.tsimport type { Ctx } from "@snapfire/fsr/host"; export async function load(_ctx: Ctx<"/">) { return { greeting: "Hello from Rust" }; }Tutorial
- 020Build a counterYou'll add a working button. The browser downloads no component code for it. Clicking it sends the event to Rust, Rust runs the handler and sends back the markup that changed.app/src/ui/Counter.tsximport { useState } from "react"; export function Counter({ start }: { start: number }) { const [n, setN] = useState(start); return ( <div className="counter"> <p>Count: {n}</p> <button onClick={() => setN(n + 1)}>Add one</button> <button onClick={() => setN(start)}>Reset</button> </div> ); }Tutorial
- 030Add a working cartYou'll build a cart whose contents live in the session on the server. Adding an item is one typed call from the browser. The server holds the only copy of the cart, so every page reads the same quantities from the session.app/routes/cart/actions.tsimport { action, fail } from "@snapfire/fsr/host"; import type { ActionCtx } from "@snapfire/fsr/host"; import type { AddToCart, RemoveFromCart } from "@schemas/cart"; export const addToCart = action(async ({ input, session }: ActionCtx<AddToCart>) => { const key = String(input.product_id); const wanted = (session.cart[key] ?? 0n) + input.quantity; if (wanted <= 0n) delete session.cart[key]; else session.cart = { ...session.cart, [key]: wanted }; const count = Object.values(session.cart).reduce((n, q) => n + q, 0n); return { lines: session.cart, count }; }); export const removeFromCart = action(async ({ input, session }: ActionCtx<RemoveFromCart>) => { const key = String(input.product_id); delete session.cart[key]; const count = Object.values(session.cart).reduce((n, q) => n + q, 0n); return { lines: session.cart, count }; });Tutorial
Data and rendering
- 100Cache loaders and rendersYou'll make pages fast by letting FSR do the caching. FSR works out which routes read nothing of the request, renders those once and answers them from a file. For the ones that do read the request, it caches the expensive half and leaves the rest live.config/app.toml[cache] capacity = 1000 ttl = "1m"Tutorial
- 110Prerender a blog at build timeYou'll build a three-post blog where every post is a file on disk before the first request arrives. A post that isn't in the set still renders live, so a new post is served before the next prerender picks it up.app/routes/post/[slug]/page.loader.tsimport type { Ctx } from "@snapfire/fsr/host"; import { fail } from "@snapfire/fsr/host"; import { POSTS } from "@src/posts"; export async function load({ params }: Ctx<"/post/{slug}">) { const post = POSTS.find((p) => p.slug === params.slug); if (!post) fail("not_found", `there is no post ${params.slug}`); return { title: post.title, date: post.date, body: post.body }; } export const paths = () => POSTS.map((post) => ({ slug: post.slug }));Tutorial
- 120Find out what won't compileFSR turns your TypeScript into data a Rust host runs. Some code can't be expressed that way. You'll write some, read what the build says about it and see the two kinds of message it gives.app/routes/page.loader.tsimport type { Ctx } from "@snapfire/fsr/host"; export async function load(_ctx: Ctx<"/">) { const stamp = new Date().toISOString(); return { greeting: "Hello from Rust", stamp }; }Tutorial
- 130Validate a formYou'll build a sign-up form that rejects bad input, comes back with the message beside the field and leaves everything the visitor typed exactly where it was. It works with or without JavaScript and each path needs different things from you.app/schemas/signup.tsexport interface SignUp { email: string; display_name: string; }Tutorial
- 140Stream a slow pageYou'll take a page that waits four seconds on a recommendation service and make the rest of it arrive immediately, with the slow region filling in where it sits. The loading state is one small component in its own file.app/routes/product/[id]/page.loader.tsimport type { Ctx } from "@snapfire/fsr/host"; export async function load({ params, services }: Ctx<"/product/{id}"> ) { const product = await services.shopping.getProduct({ id: params.id }); const suggestions = await services.recommend.forProduct({ id: params.id }); return { product, suggestions }; }Tutorial
- 150Share state with the storeYou'll build an order page whose islands share state the way signals would: a badge in the header and a stepper on the page read one value, a picker writes two values at once and a write that the server refuses is taken back. Shared state is a key in the store and state one island owns is React's useState.app/src/ui/Sizes.tsximport { set, transaction } from "@snapfire/fsr/client/store"; import { useStore } from "@snapfire/fsr/client/react"; import { price, size } from "@src/store"; export default function Sizes({ sizes }: { sizes: { label: string; price: number }[] }) { const [chosen] = useStore(size, "250g"); return ( <div className="sizes"> {sizes.map((s) => ( <button key={s.label} type="button" aria-pressed={s.label === chosen} onClick={() => transaction(() => { set(size, s.label); set(price, s.price); }) } > {s.label} </button> ))} </div> ); }Tutorial
- 160Add share cardsYou'll give each page of a small recipe site its own share card: the picture a chat app or a social site shows when someone pastes the link. Each card is drawn from the page's own data by the server that serves the page.Tutorial
- 170Port async components from Next.jsYou'll write a report page the way Next's App Router teaches, with an async component that awaits its data. fsr will refuse it and tell you where the await goes. Then you'll split the chart into a component that loads on demand with React.lazy. fsr still renders it on the server, so the first paint has the finished chart.Tutorial
Choosing a frontend
- 200Write your islands in VueYou'll start a fresh app, point it at Vue and write a single-file component the server renders and the browser hydrates. Vue is the only framework in the import map, the vendor tree, the bundle and the type declarations. The pages are lowered templates as usual and the Vue component is lowered too.app/src/ui/Tonight.vue<script setup lang="ts"> import { ref } from "vue"; import { useStore } from "@snapfire/fsr/client/vue"; import { plannedCount } from "@src/store"; const props = defineProps<{ count: number }>(); const held = useStore(plannedCount, props.count); const open = ref(false); </script> <template> <div class="tonight"> <button class="tonight-count" @click="open = !open">{{ held.value }} for tonight</button> <p v-if="open" class="tonight-note">Kept in the session. This is Vue state in the root layout, so it stays open as you move between recipes.</p> </div> </template> <style scoped> .tonight-count { border: 1px solid #1b1d21; border-radius: 999px; background: #fff; padding: 6px 14px; font: inherit; cursor: pointer; } </style>Tutorial
- 205Write your islands in SvelteYou'll put a Svelte counter and a React counter on one page and make them share one number. Click either one and both move. The server renders both counters with the starting value, so the number is in the markup before any script runs.app/src/ui/SvelteCounter.svelte<script lang="ts"> import { useStore } from "@snapfire/fsr/client/svelte"; import { clicks } from "@src/store"; let { start }: { start: number } = $props(); const n = useStore(clicks, start); </script> <div class="counter"> <span class="label">Svelte</span> <button aria-label="Decrease" onclick={() => ($n -= 1)}>-</button> <output>{$n}</output> <button aria-label="Increase" onclick={() => ($n += 1)}>+</button> </div>Tutorial
- 210Write islands as web componentsYou'll build a tool library. The pages are the same lowered templates as any other FSR app. The interactive bits are custom elements, which the browser upgrades in place over markup the server already wrote. When a region needs to talk to the server it uses htmx, asking the host for one segment of a route. The import map ends up naming htmx and nothing else.app/src/elements/shed-tally.tsimport { get, subscribe } from "@snapfire/fsr/client/store"; import { reservedCount } from "../store.js"; class ShedTally extends HTMLElement { #stop: (() => void) | null = null; connectedCallback(): void { const button = this.querySelector<HTMLButtonElement>("button.tally"); const panel = this.querySelector<HTMLElement>(".tally-panel"); if (!button || !panel) return; const toggle = () => { panel.hidden = !panel.hidden; button.setAttribute("aria-expanded", String(!panel.hidden)); }; const show = (count: unknown) => { if (typeof count === "number") button.textContent = `${count} reserved`; }; button.addEventListener("click", toggle); show(get(reservedCount)); const unsubscribe = subscribe(reservedCount, show); this.#stop = () => { button.removeEventListener("click", toggle); unsubscribe(); }; } disconnectedCallback(): void { this.#stop?.(); this.#stop = null; } } customElements.define("shed-tally", ShedTally);Tutorial
- 220React and Vue on one pageYou'll put a React island and a Vue island in the same document, have them share one piece of state, then navigate between a page whose island is Vue and a page whose island is React while the layout stays exactly where it is. At the end you'll measure what it costs in download size.app/src/ui/Holdings.vue<script setup lang="ts"> import { useStore } from "@snapfire/fsr/client/vue"; import { watchedKey } from "@src/store"; const props = defineProps<{ rows: { symbol: string; shares: number }[]; watched: string }>(); const held = useStore(watchedKey, props.watched); </script> <template> <table class="holdings"> <tbody> <tr v-for="row in props.rows" :key="row.symbol" :class="{ on: row.symbol === held.value }"> <td> <button type="button" @click="held.value = row.symbol">{{ row.symbol }}</button> </td> <td>{{ row.shares }}</td> </tr> </tbody> </table> </template>Tutorial
- 230Write your pages in TeraYou'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.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>Tutorial
- 240Style an app with TailwindYou'll style a status page with Tailwind utilities. Tailwind CSS v4 is built into the bundler and the build hands it the exact list of classes your pages render. It also tells you about a class it can't see before a visitor finds the missing style.Tutorial
Growing the app
- 300Run one app on two domainsYou'll make one binary answer for two host names and serve something different on each. The loader reads which domain it was asked on. A forged Host header can't change what it reads.config/app.toml[server] hosts = ["acme.example", "widgets.example"]Tutorial
- 310Put a docs site inside your appYou'll mount a whole second FSR application inside the first one, at a prefix, sharing its navbar and its theme. The two applications can live in separate repositories with separate owners and release schedules. One binary still serves both on one domain. The page you're reading is a mounted site.terminal$ fsr new docs --site --at /docs --into ../shell wrote docs/.gitignore wrote docs/config/app.toml wrote docs/config/development.toml wrote docs/config/production.toml wrote docs/app/importmap.json wrote docs/app/src/main.ts wrote docs/app/routes/layout.tsx wrote docs/app/routes/page.loader.ts wrote docs/app/routes/page.tsx wrote docs/app/routes/not-found.tsx wrote docs/app/routes/error.tsx wrote docs/app/styles/docs.css wrote docs/app/generated/plan.sexp wrote docs/app/generated/contracts/schemas.json wrote docs/app/.fsr-bundle/routes/layout.tsx wrote docs/app/.fsr-bundle/routes/error.tsx wrote docs/app/.fsr-bundle/routes/not-found.tsx wrote docs/app/.fsr-bundle/routes/page.tsx wrote docs/app/generated/shell.d.ts wrote docs/app/generated/uploads.d.ts wrote docs/app/generated/assets.d.ts wrote docs/app/generated/assets.json wrote docs/app/generated/assets.map.json wrote docs/app/generated/native.d.ts wrote docs/app/generated/services.d.ts wrote docs/app/generated/elements.d.ts wrote docs/app/generated/head.ts wrote docs/app/generated/fsr.ts wrote docs/app/generated/islands.ts wrote docs/app/generated/client.ts wrote docs/app/generated/testing.ts wrote docs/app/tsconfig.json wrote docs/app/tsconfig.build.json types @snapfire/fsr/authoring fsr 0.16.4 types @snapfire/fsr/client fsr 0.16.4 wrote [site] docs at /docs in docs/config/app.toml wrote [sites.docs] in ../shell/config/app.toml next fsr use docs/app react # only if the application wants React; also vue, elements, htmx or tera next fsr dev docs/appTutorial
- 320Port a Next.js routeYou'll port a Next product page to FSR: the data fetch, the server action and the client component. You end up with a running app whose API client, request types and response types were all generated from an OpenAPI document. The Next code is printed here, so you can follow along with only FSR installed.Tutorial
- 325Move a Next.js app with fsr migrateYou'll run fsr migrate over a small Next.js app with a layout, metadata, a server action, next/link and next/image, read what it prints and finish the lines it leaves for you. 320 ports a route by hand. This one lets the command do the moving.shell$ fsr migrate guestbook-next guestbook from Next.js App Router wrote 8 files under guestbook next fsr add guestbook/app clsx@2.1.1 next fsr check guestbook/appTutorial
- 330Add a JavaScript libraryYou'll add Chart.js with fsr add, draw a chart with it and see how FSR vendors the library and names it in the import map. Then you'll hit the missing-declarations failure and fix it.terminal$ fsr add shop/app chart.js@4.4.7 added chart.js chart.js/chart.bundle.mjs 202502 bytesTutorial
- 340Add Google Analytics with a consent bannerThis continues the shop app from 330. You will add a cookie consent banner, load Google Analytics only after the analytics category is accepted and keep the measurement id in configuration so each deployment sets its own.app/src/consent.tsimport { acceptedCategory, run } from "vanilla-cookieconsent"; import { loadAnalytics } from "./analytics"; function onConsent() { if (acceptedCategory("analytics")) { loadAnalytics(); } } run({ onConsent, onChange: onConsent, categories: { necessary: { enabled: true, readOnly: true }, analytics: {}, }, language: { default: "en", translations: { en: { consentModal: { title: "Cookies", description: "We use cookies to measure traffic.", acceptAllBtn: "Accept all", acceptNecessaryBtn: "Reject all", }, preferencesModal: { title: "Preferences", acceptAllBtn: "Accept all", acceptNecessaryBtn: "Reject all", savePreferencesBtn: "Save", sections: [ { title: "Necessary", description: "Needed for the site to work.", linkedCategory: "necessary" }, { title: "Analytics", description: "Anonymous traffic measurement.", linkedCategory: "analytics" }, ], }, }, }, }, });Tutorial
- 350Protect a routeYou'll put a page behind a sign-in, send an anonymous visit to the sign-in page and read who the visitor is from a loader. The check is one file and it runs before any of your routes do.app/middleware.tsimport type { MiddlewareCtx } from "@snapfire/fsr/host"; export async function middleware({ request, identity }: MiddlewareCtx) { if (request.path.startsWith("/account") && !identity) { return Response.redirect(`/auth/login?return_to=${request.path}`, 303); } }Tutorial
- 355Sign in with a passkeyYou'll let a signed-in visitor add a passkey, sign in with it on its own and then require it after the password. The browser's prompt runs from one client call and the host checks every answer.config/app.toml[auth] login = "/sign-in" [[auth.methods]] name = "password" provider = "file" [[auth.methods]] name = "passkey" provider = "passkey"Tutorial
- 356Sign in with GoogleYou'll switch the sign-in to Google, keep working against a stand-in form until the client secret exists and see the redirect the host sends once it does. No body changes, since a body only reads identity.config/app.toml[auth] provider = "oidc" preset = "google" client_id = "1234.apps.googleusercontent.com" client_secret = "google" login = "/sign-in" [secrets.google] description = "the Google OAuth client secret"Tutorial
- 360Add light and dark themesYou'll add light and dark that is correct in the first byte the server sends. A visitor who never chooses gets their system's setting. One who chooses keeps it, because the choice lives in the session and the server reads it before it renders.Tutorial
- 370Accept a fileYou'll take a file from a form, check it before it goes anywhere and hand the bytes on. It works with JavaScript switched off.app/src/ui/DepositForm.tsximport { upload } from "@snapfire/fsr/client"; export function DepositForm({ token }: { token: string }) { async function send(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); const data = new FormData(event.currentTarget); data.set("_csrf", token); await upload("$root.deposit", data); } return ( <form method="post" action="/_sf/action/$root.deposit" encType="multipart/form-data" onSubmit={send}> <input type="hidden" name="_csrf" value={token} /> <label> Caption <input name="caption" /> </label> <label> File <input type="file" name="file" /> </label> <button type="submit">Deposit</button> </form> ); }Tutorial
- 375Call a GraphQL APIYou'll read countries from a public GraphQL API through a contract the build checks, handle the call failing and give a client an API key the application code never sees. The API is https://countries.trevorblades.com/, which needs no key.app/routes/country/[code]/page.loader.tsimport type { Ctx } from "@snapfire/fsr/host"; export async function load({ params, services }: Ctx<"/country/{code}">) { const { country } = await services.countries.country({ code: params.code }); return { code: params.code, country }; }Tutorial
- 380Embed a videoYou'll put two YouTube videos on a page. The server renders each one as a still with a play button. The player only loads for the one a visitor actually clicks. Then you'll move the stills onto your own origin, so the page asks YouTube for nothing until someone presses play.Tutorial
- 385Answer an API routeYou'll write a JSON API with a GET and a checked POST, answer a plain text download with your own Response and wrap every API call in middleware. The handlers see the same session, identity and services as a page.app/routes/api/notes/route.tsexport async function GET({ session }: Ctx<"/api/notes">) { return { notes: session.notes, count: session.notes.length }; }Tutorial
- 390Animate page changes with view transitionsYou'll build a grid of colors where clicking a tile grows it into the full swatch on the next page. Back shrinks it home again. It's the browser's own View Transitions API, driven by the navigator fsr already uses for client navigation. It takes one option and one CSS property.Tutorial
- 395Keep page state across navigationYou'll build a page with a notes field, a packing checklist and a list of hikes. Open a hike, press Back and the page comes back the way you left it: the notes still typed, the items still ticked and the list still scrolled. It takes one option on the navigator you already have.Tutorial
- 397Keep pages for reading offlineYou'll make an app installable, keep every page a visitor opened for when the network goes and tell an open page when a new build is ready. It takes one configuration section, one page and one island.config/app.toml[pwa] name = "Trails" short_name = "Trails" theme_color = "#2f5d3a" background_color = "#f4f1ea" offline = "/offline"Tutorial
Test and ship
- 400Test a loaderYou'll test a loader with fsr test. The test runs your loader through the same interpreter that serves requests, with its services stubbed, so nothing else has to be running.app/tests/product/loader.test.tsimport { load } from "@routes/product/[id]/page.loader"; import { assert, ctx, test } from "@snapfire/fsr/testing"; const coat = { id: "1", name: "Chore coat", blurb: "Waxed cotton, four pockets.", price_cents: 14800n }; test("the loader hands the page the product the id names", async () => { const c = ctx<void, "/product/{id}">({ params: { id: "1" }, services: { shop: { getProduct: () => coat } }, }); const { product } = await load(c); assert.equal(product.name, "Chore coat"); assert.equal(product.price_cents, 14800n); });Tutorial
- 410Ship itYou'll turn an app into a directory and a binary you copy to a server. First you'll run the checks that catch problems that only appear in production.terminal$ fsr bundle shop/app --verbose serve/static/js/app serves /static/js/app serve/static/js/vendor serves /static/js/vendor serve/static/css serves /static/css serve/static/js/fsr serves /static/js/fsr app/clients/shop.openapi.json read by the host app/generated/contracts read by the host app/generated/plan.sexp read by the host app/importmap.json read by the host config read by the host config/bundle.toml read by the hostTutorial
- 420Debug a page in productionYou'll read a request back as one tree that shows which loader waited on which service, what ran at the same time and which cache answered. You'll do it first from the dev server and then from the binary you actually deploy, where the endpoint you just used doesn't exist.terminal$ curl -s localhost:3000/__fsr/traces | jq '.[-1].spans[] | {name, ms, outcome, fields}' request 19.92ms ok {"method": "GET", "path": "/product/9", "status": "200"} source 16.25ms ok {"id": "layout", "memo": "miss", "node": "1"} source 16.60ms ok {"id": "product.$id", "node": "2"} call 15.99ms ok {"service": "catalog", "method": "getProduct"} call 15.54ms ok {"service": "pricing", "method": "quote"} render 1.37ms {"module": "shell#document"} render 1.16ms {"module": "routes/layout.tsx#default"} render 0.10ms {"module": "routes/product/[id]/page.tsx#default", "cache": "miss"}Tutorial
- 430Make a page fast420 reads a trace to find out what a request did. This tutorial uses the trace to find which part of a slow page to cut and to check that the cut worked. The changes below are ordered by how much they usually save.terminalcurl -s localhost:11110/__fsr/traces | head -40Tutorial
- 440Find broken linksA page can render correctly while some of its requests fail. You'll learn what the deploy tree actually serves, why development never shows you the difference and how to catch a failed request that leaves no mark on the screen.terminalserve/static/js/fsr serves /static/js/fsr serve/static/js/app serves /static/js/app serve/static/js/vendor serves /static/js/vendor app/generated/plan.sexp read by the host config read by the host 117 files, 4013390 bytes under dist place beside it: the binary, the logging configurationTutorial
- 450Set a content security policyYou'll write a Content-Security-Policy, find out what it blocks before it blocks anything, read each violation in the dev panel and see why nonces don't work for inline scripts here.config/app.toml[document.csp] default-src = ["'self'"] script-src = ["'self'"] style-src = ["'self'", "'unsafe-inline'"] img-src = ["'self'", "data:"] base-uri = ["'none'"] object-src = ["'none'"] frame-ancestors = ["'self'"]Tutorial
Desktop
- 700Open your app in a desktop windowYou'll open an ordinary FSR app in a native window, name the window and choose how the window reaches the host. The pages, loaders and islands are the ones a browser gets. The window is the system webview: WebKit on macOS and Linux, WebView2 on Windows.config/app.toml[desktop] name = "Shop" serve = "scheme" cookies = "disk"Tutorial
- 710Open a file from the desktop appYou'll add an Import CSV button to the shop from 700. In the desktop window it opens the system file dialog limited to CSV files and the shop imports the products from the file you choose. A second button opens a help page in the system browser. In an ordinary browser tab the same page shows a note instead of the buttons.app/src/ui/ImportProducts.tsximport { useEffect, useState } from "react"; import { desktop } from "@snapfire/fsr/client"; export function ImportProducts() { const [inWindow, setInWindow] = useState(false); const [chosen, setChosen] = useState<string | null>(null); const [problem, setProblem] = useState<string | null>(null); useEffect(() => { setInWindow(desktop.isDesktop()); }, []); async function pick() { setProblem(null); try { const paths = await desktop.openFile({ filters: [{ name: "CSV", extensions: ["csv"] }] }); if (paths) setChosen(paths[0]); } catch (error) { setProblem(error instanceof Error ? error.message : String(error)); } } async function help() { try { await desktop.openExternal("https://example.com/csv-format"); } catch (error) { setProblem(error instanceof Error ? error.message : String(error)); } } if (!inWindow) { return <p className="import-note">Importing a CSV works in the desktop app.</p>; } return ( <div className="import"> <button type="button" onClick={pick}> Import CSV </button> <button type="button" onClick={help}> CSV format </button> {chosen ? <p className="chosen">{chosen}</p> : null} {problem ? <p className="problem">{problem}</p> : null} </div> ); }Tutorial
Rust
- 500Move a loader into RustYou'll take one loader out of TypeScript and answer it from Rust. The page, the route and the tests don't change. At the end you'll load the page and see your Rust string in the markup.src/main.rsuse std::sync::Arc; use snapfire_fsr_core::{Data, Value}; use snapfire_fsr_host::Host; #[tokio::main] async fn main() -> std::io::Result<()> { let host = Host::from_cwd() .and_then(|builder| { builder .source_override("product.$id", |ctx| async move { let id = ctx.params.get("id").cloned().unwrap_or_default(); let product: Data = [ ("id".to_owned(), id.into()), ("name".to_owned(), "Chore coat".into()), ("blurb".to_owned(), "Answered by Rust.".into()), ("price_cents".to_owned(), 14800i64.into()), ] .into_iter() .collect(); Ok([("product".to_owned(), Value::Map(product))].into_iter().collect()) }) .build() }) .map_err(std::io::Error::other)?; print!("{}", host.report()); let listen = host.listen().to_owned(); Arc::new(host).serve(&listen).await }Tutorial
- 510Add FSR to an existing Rust serviceThe FSR host is a tower::Service, so you can mount it inside a binary you already have, with your own runtime, middleware and listener.src/main.rsuse std::sync::Arc; use snapfire_fsr_host::Host; #[tokio::main] async fn main() -> std::io::Result<()> { let host = Host::from_cwd().and_then(|b| b.build()).map_err(std::io::Error::other)?; print!("{}", host.report()); let listen = host.listen().to_owned(); Arc::new(host).serve(&listen).await }Tutorial
- 520Serve Tera templates from RustYou'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.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>Tutorial