Playbooks
The handler crates are designed to be picked à la carte, but most applications fall into a few recognizable shapes. These are complete, copy-paste starting points for those shapes — take one, delete what you don't need, and adjust the placeholders (yourdomain.example) to your origins.
Order matters, and each tuple below encodes it. Handlers run left to right, and before_send hooks run in reverse order on the way out — which is why Logger goes first: it runs its formatter last, observing the final response, after compression and everything else.
Server-rendered website
The full batteries-included set for a site that renders HTML, uses session cookies, and serves its own assets:
use trillium::Conn;
use trillium_compression::Compression;
use trillium_cookies::CookiesHandler;
use trillium_csrf::Csrf;
use trillium_head::Head;
use trillium_logger::Logger;
use trillium_router::Router;
use trillium_sessions::{MemoryStore, SessionHandler};
use trillium_static::{crate_relative_path, StaticFileHandler};
fn main() {
trillium_smol::run((
Logger::new(),
Compression::new(),
Head::new(),
CookiesHandler::new(),
SessionHandler::new(MemoryStore::new(), "01234567890123456789012345678901123"),
Csrf::new(),
Router::new()
.get("/", |conn: Conn| async move { conn.ok("home") })
.post("/signup", |conn: Conn| async move { conn.ok("signed up") }),
StaticFileHandler::new(crate_relative_path!("examples/files")).with_index_file("index.html"),
));
}
Why this order:
Loggerfirst, so itsbefore_sendruns last and logs the response as sent.Compressionbefore anything that sets a body, so itsbefore_sendwraps them all.Headbefore the router, so route handlers seeHEADrequests asGETand never build a body that would be discarded.CookiesHandlerbeforeSessionHandler, which stores the session key in a cookie.csrf()before anything with side effects. It needs no configuration when the site's pages post back to the site itself.- The static file handler last: it only runs if no route matched.
There's no CORS handler here — a site whose pages talk only to their own origin doesn't need one, and adding one wouldn't make it more secure (see the browser is the enforcement point).
The MemoryStore and the inline session secret are development conveniences: sessions vanish on restart, and a secret in source is a secret leaked. In production, use a persistent session store and load the secret from the environment.
JSON api with a browser frontend on another origin
A cookie-authenticated api at api.yourdomain.example serving a frontend at app.yourdomain.example. The frontend's origin shows up twice — CORS lets its pages read responses, and CSRF lets its pages make state-changing requests:
use trillium::{Conn, KnownHeaderName, Method};
use trillium_cookies::CookiesHandler;
use trillium_cors::Cors;
use trillium_csrf::Csrf;
use trillium_logger::Logger;
use trillium_router::Router;
use trillium_sessions::{MemoryStore, SessionHandler};
const FRONTEND: &str = "https://app.yourdomain.example";
fn main() {
trillium_smol::run((
Logger::new(),
Cors::allow_origins([FRONTEND])
.allow_credentials()
.allow_methods([Method::Delete, Method::Put])
.allow_headers([KnownHeaderName::ContentType]),
Csrf::new().with_trusted_origins([FRONTEND]),
CookiesHandler::new(),
SessionHandler::new(MemoryStore::new(), "01234567890123456789012345678901123"),
Router::new()
.get("/widgets", |conn: Conn| async move { conn.ok("[]") })
.post("/widgets", |conn: Conn| async move { conn.ok("created") }),
));
}
allow_credentials is what lets the browser send the session cookie cross-origin, and it's why the origin must be named rather than wildcarded. allow_headers([KnownHeaderName::ContentType]) is what permits application/json request bodies — content-type is only CORS-safelisted for form encodings.
If the api is authenticated by a bearer token instead of cookies, most of this falls away: drop csrf(), CookiesHandler, SessionHandler, and allow_credentials, and add authorization to allow_headers. Cross-site request forgery only applies to ambient credentials like cookies, and without credentials the CORS policy is just naming who may read.
Variations
- Behind a reverse proxy or load balancer: add
Forwardingat the front of the tuple soconn.peer_ip()and the logger report the real client rather than the proxy. - Request ids: add
ConnIdafter the logger to tag each request with an identifier usable in log formats and response headers. - Conditional
304s: addCachingHeadersbefore the router forETag/Last-Modifiedhandling. - Single-binary deploys: swap
trillium-staticfortrillium-static-compiled, which embeds the asset directory at compile time.