Skip to content
★ GitHubGet started

Views and assets

“How does this response get to the browser” has three different shapes in a Loco app — server-rendered HTML, a JSON API behind a separately-built SPA, or a fully embedded single binary — and Loco lets you pick without changing how controllers work. This page explains the model behind that choice. For the how-to of writing a specific view or wiring the static middleware, see Render server-side views; for the exhaustive middleware/config keys, see the middleware catalog and feature flags reference.

The separation controllers don’t have to care about

Section titled “The separation controllers don’t have to care about”

Loco keeps the traditional split of responsibilities — a controller parses the request and calls into models; a view is responsible only for shaping the final response — and makes that split concrete with one trait:

pub trait ViewRenderer {
fn render<S: Serialize>(&self, key: &str, data: S) -> Result<String>;
}

A controller never talks to Tera, or to any specific templating engine, directly — it takes a v: impl ViewRenderer (typically via the ViewEngine<E> extractor) and calls format::render().view(&v, "home/hello.html", data!({..})). The engine behind that trait is decided once, at the Initializer level — swap ViewEngine<TeraView> for ViewEngine<YourEngine> and every existing call site keeps compiling, because it was only ever coupled to the trait, not to Tera specifically. This is the same escape-hatch pattern described in Why batteries included: Tera is the built-in, ViewRenderer is the seam you use if you need something else.

For pure JSON APIs, “the view” can be as simple as a #[derive(Serialize)] struct shaped by hand and returned via format::json(..) — no template engine in the loop at all. Most real apps mix both: JSON views for an API surface, Tera views for a handful of server-rendered pages (an admin panel, a marketing page, an email-verification landing page).

Three deployment shapes, one set of controller code

Section titled “Three deployment shapes, one set of controller code”

The default shape: TeraView reads templates from assets/views/**/*.html on disk at request time, alongside static files served from assets/static/ through the static middleware. This is the natural choice when the app itself renders the HTML the browser gets — templates can be edited without a rebuild (cargo loco start picks up a changed .html file on the next request), which matters during active UI development.

Here Loco’s job shrinks to two things: serve a JSON API, and serve the SPA’s built static assets (the dist/-style output of a separate frontend build) through the same static middleware, usually with a fallback to index.html for client-side routing. There’s no TeraView in the picture for the API surface at all — the split between “backend serves data” and “frontend owns rendering” is total, and Loco’s role is just: API controllers, plus a static file server pointed at wherever the frontend build lands.

embedded_assets: one flag, two subsystems swapped together

Section titled “embedded_assets: one flag, two subsystems swapped together”

embedded_assets is a build-time Cargo feature that changes where the bytes come from without changing a single controller or view call:

loco-rs = { version = "...", features = ["embedded_assets"] }

With it enabled, the entire assets/ directory — templates and static files — is scanned at compile time and embedded directly into the binary. What makes this worth calling out as a distinct architectural idea, rather than just “a smaller deployment,” is that it isn’t one subsystem being swapped: both the Tera view engine and the static-assets middleware are simultaneously replaced with embedded-reading variants (views::engine_embedded in place of views::engine, static_assets_embedded in place of static_assets) behind the same #[cfg(embedded_assets)] gate. One flag flips two independently-registered subsystems in lockstep, so a template lookup and a static-file request both resolve against the same in-binary asset table rather than one reading disk and the other reading memory. From application code, nothing changes — the ViewEngine/ViewRenderer and static-middleware config keys are identical either way.

The trade-off is the mirror image of SSR’s live-editing convenience: a single-binary deploy with atomic code/asset updates and no filesystem asset directory to manage in production, at the cost of a full recompile for any asset change and a larger binary. Projects often use plain filesystem assets in development (fast iteration) and flip embedded_assets on for release builds (simpler deployment) — the same controller and view code runs unmodified in both.

Why this is one flag and not per-subsystem toggles

Section titled “Why this is one flag and not per-subsystem toggles”

It would be possible to let the view engine and the static middleware be embedded independently — but that would create deployment shapes where templates are baked into the binary while static files are read from disk (or vice versa), with no clear operational benefit and a real risk of the two drifting (a rebuild updates embedded templates but not the separately-deployed static folder, or the reverse). Coupling both under one feature flag makes “embedded” a single, coherent deployment mode rather than a matrix of partial states to reason about — consistent with the broader theme in Why batteries included of collapsing a class of decisions into one well-tested default, with a clearly labeled way to opt out (here, just don’t enable the feature).

See Render server-side views for the concrete assets/ directory layout, writing a Tera view and its controller wiring, and swapping in a custom ViewRenderer; see the feature flags reference for embedded_assets’s place in the full flag matrix, and the middleware catalog for the static middleware’s config keys.