Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Laterite

Laterite is a content management framework for Rust. It gives an application a descriptor-driven admin panel, authentication and permissions, a namespaced migration system, and typed settings, all built on Axum, sqlx, and Postgres.

The framework is assembled from small crates, each owning one concern:

CrateConcern
laterite-coreModule registration and the migration runner
laterite-authBackend users, roles, sessions, and permissions
laterite-adminDescriptor-driven list and form screens
laterite-settingsTyped settings models stored as JSONB
laterite-cliAdministrative commands (create user, reset password)

How this guide is organized

  • Getting Started walks through adding Laterite to a project and running the admin panel.
  • Extending Laterite documents each capability as it is built: how to declare it, wire it into an application, and the guarantees it provides.
  • Reference links to the generated API documentation for every crate.

This guide grows one feature at a time alongside the framework. If a capability is not documented here yet, it is not part of a shipped release.

Installation

The fastest way to a running Laterite application is the lat command-line tool, which scaffolds a project, sets up the database, and creates the first administrator in one guided step.

Prerequisites

  • Rust (stable), installed with rustup.
  • One of PostgreSQL, MySQL/MariaDB, or SQLite. SQLite needs nothing extra; it is just a file.

Install the CLI

cargo install laterite-cli

This installs the lat binary. It bundles every database driver, so one binary works with any supported database.

Create an application

Run lat new and answer the prompts:

lat new

It asks for:

  • the application name (a crate name, such as acme),
  • a display timezone (type to search the IANA list),
  • a database (PostgreSQL, MySQL/MariaDB, or SQLite) and its connection details, offering to create the database if it does not exist,
  • the first administrator (username, email, password).

It scaffolds the project, applies the framework migrations, and creates the administrator. When it finishes:

cd acme
cargo run

Open http://127.0.0.1:8080/admin and sign in.

What it generates

The project stands alone (its own Cargo workspace) and follows a small convention:

acme/
├── Cargo.toml
├── config/
│   ├── default.toml     # committed defaults (listen address, timezone)
│   └── local.toml       # git-ignored; holds the database URL
├── src/
│   ├── main.rs          # loads config, connects, migrates, serves the admin
│   └── migrations.rs    # this application's own migrations (empty to start)
└── storage/             # runtime data (the SQLite file, and later cache/logs)

src/main.rs is the whole application (abbreviated):

let config: AppConfig = laterite_core::config::load(Path::new("config"), "APP")?;
let db = laterite_core::db::connect(&config.database).await?;

// The framework's built-in migrations, then this application's own.
let mut sets = laterite_admin::builtin_migrations();
sets.extend(migrations::migrations());
laterite_core::migration::run(&db.pool, db.backend, &sets).await?;

let auth =
    laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default());
let router = laterite_admin::router(
    auth,
    db,
    Vec::new(), // application resources (list/form screens)
    Vec::new(), // application settings models
    Vec::new(), // application permissions
    admin_config,
);
axum::serve(listener, router).await?;

The three vectors are the application’s own resources, settings models, and permissions; an application with none yet passes them empty.

Check the setup

lat doctor, run from an application’s directory, verifies it is ready to serve: the configuration loads, the timezone is valid, storage/ is writable, the database is reachable, and the framework’s tables are present. It exits non-zero if anything fails, so it can gate a deploy:

cd acme
lat doctor

Managing administrators later

The first administrator is created during lat new. To add or recover one later (a scripted install, or a forgotten password), use the CLI directly against the application’s database:

lat admin create editor --email editor@acme.test --first-name Editor
lat admin reset-password editor

Both prompt for a password, or pass --generate to have a strong one created and printed. No default password is ever shipped, and on a fresh install with no accounts the admin also serves a one-time first-run setup screen instead of the login form.

Configuration

Laterite reads layered configuration, so one build runs across environments without code changes. These files are deployment-level: per-install branding and per-operator preferences are edited in the admin, not here (see What is not configured here).

Layers

An application calls the loader with a config directory and an environment-variable prefix. Layers apply in order, later overriding earlier:

  1. default.toml (required): the base configuration.
  2. <APP_ENV>.toml (optional): environment-specific overrides. APP_ENV selects the file and defaults to development. Create staging.toml, production.toml, testing.toml, and so on.
  3. local.toml (optional): personal developer overrides, kept out of version control.
  4. Environment variables PREFIX__SECTION__KEY: override any value (e.g. ACME__DATABASE__URL).

So APP_ENV=production loads default.toml then production.toml. A secure_cookie = true in production.toml turns the Secure cookie on only in that environment; environment variables win over all files, which suits secrets and container deployments.

Sections

[server]
listen = "127.0.0.1:8080"        # HTTP bind address

[database]
url = "postgres://localhost/acme_dev"
max_connections = 10             # optional
acquire_timeout_secs = 5         # optional

[backend]
secure_cookie = false            # set true behind HTTPS in production
timezone = "UTC"                 # default admin display timezone (IANA name); storage stays UTC

[auth]
session_ttl_secs = 43200         # session lifetime, 12h default
max_failures = 5                 # failed logins before a username is locked out
failure_window_secs = 900        # window the failures are counted over

Every [auth] and [backend] key is optional and falls back to a built-in default when omitted.

What is not configured here

Deployment config is per-environment. Two related concerns live elsewhere, so they can change at runtime without a redeploy:

  • Branding (application name, colour-mode default, logo) is an operator-editable setting stored in the database and changed from the admin.
  • Preferences are per-operator and set from the admin. An operator’s own display timezone is one: backend.timezone is only the default until they choose their own from Preferences. See Dates and Timezones.

Live Reload in Development

A Laterite application is a compiled binary, so a source change takes effect once the binary is rebuilt and rerun. A small tool loop makes that automatic and keeps the listening port bound across rebuilds, so the browser reconnects on its own instead of hitting a refused connection.

Two tools cover it:

  • systemfd binds the listening socket once and passes it to each new build of your server.
  • watchexec reruns the server when a source file changes.
cargo install systemfd
brew install watchexec   # or cargo install watchexec-cli

Reuse a passed socket

For systemfd to hand its socket to your server, the server reuses a socket inherited from the environment when one is present, and binds its configured address otherwise. Add listenfd and take the socket in main:

use listenfd::ListenFd;
use tokio::net::TcpListener;

let listener = match ListenFd::from_env().take_tcp_listener(0)? {
    Some(std_listener) => {
        std_listener.set_nonblocking(true)?;
        TcpListener::from_std(std_listener)?
    }
    None => TcpListener::bind("127.0.0.1:8080").await?,
};
axum::serve(listener, app).await?;

The None arm is the normal path: a plain cargo run, and production, bind the address directly. Only the development loop passes a socket.

Run the loop

Wrap the run command with both tools. systemfd stays as the long-lived parent that owns the socket; watchexec restarts the build under it:

systemfd --no-pid -s http::8080 -- \
    watchexec -r -e rs,html,css,toml -- \
    cargo run -p acme-api

The admin templates are compiled into the binary, so watching .html and .css alongside .rs means an edit to a screen’s markup or the stylesheet triggers a rebuild and shows up on the next reconnect. A justfile recipe keeps the command to hand:

dev:
    systemfd --no-pid -s http::8080 -- \
        watchexec -r -e rs,html,css,toml -- \
        cargo run -p acme-api

See compiler errors as you type

The reload loop shows build output in the server’s terminal. For a dedicated, navigable view of compiler and clippy errors while you edit, run bacon in a second terminal:

cargo install bacon   # or brew install bacon
bacon clippy

Dates and Timezones

Laterite stores every timestamp in UTC and converts it to a display timezone only when rendering. Nothing about how a date is shown ever changes what is stored, so timezones are purely a presentation concern.

How a timestamp is displayed

List columns declare a kind. A column marked as a datetime is parsed from its stored UTC value, converted to the viewer’s timezone, and formatted human-readably (for example 14 Aug 2026, 15:53) instead of the raw ISO string:

use laterite_admin::list::ListColumn;

ListColumn::new("created_at", "Created").datetime();
ListColumn::new("published_on", "Published").date();
ListColumn::new("is_active", "Active").yes_no();

The kinds are text (the default), datetime, date, time, and a yes_no boolean. A value that cannot be parsed falls back to the raw string rather than erroring.

Which timezone is used

The display timezone is resolved for each request in two tiers:

  1. The signed-in operator’s own preference, if they have set one.
  2. Otherwise the deployment default from backend.timezone (an IANA name such as Asia/Kolkata), which itself falls back to UTC.

An operator sets their own timezone from Preferences (the user menu, top right). Choosing a zone makes every date in the admin render in it for that operator only; choosing “Use the deployment default” clears the preference so they follow backend.timezone again. Because storage is always UTC, switching timezones never migrates or rewrites any data.

Settings Models

A settings model holds a group of configuration values that an operator edits from the admin panel: a site title, a tagline, feature toggles. In Laterite a settings model is a plain Rust struct. It is stored as a single JSON value keyed by a stable code, so adding or removing a field never needs a database migration, and access to it is checked by the compiler.

This is provided by the laterite-settings crate.

Define a settings model

Derive Serialize, Deserialize, and Default, then implement SettingsModel with a stable CODE. Give every field #[serde(default)] so a stored value that predates a new field still deserializes:

use laterite_settings::SettingsModel;
use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SiteSettings {
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub tagline: String,
    #[serde(default)]
    pub maintenance_mode: bool,
}

impl SettingsModel for SiteSettings {
    const CODE: &'static str = "acme.site";
}

The CODE is the storage key. Choose it once and never change it, the way you would treat a table name. Namespacing it to your application (acme.site) keeps it from colliding with settings declared by other modules.

The settings table

The settings store lives in a single settings table. laterite_admin::builtin_migrations() already includes its migration, so once you have run migrations the table is there.

Read and write, typed

Load a model with load. When nothing has been saved yet, it returns the model’s Default rather than an error, so callers never handle an “unset” case:

let settings: SiteSettings = laterite_settings::load(&pool).await?;
println!("{}", settings.title);

Save with save, which upserts the whole struct as one JSON value:

let settings = SiteSettings {
    title: "Acme".into(),
    tagline: "We build things".into(),
    maintenance_mode: false,
};
laterite_settings::save(&pool, &settings).await?;

Read and write, untyped

The admin settings screen renders and stores any registered model without knowing its concrete type. For that path, get and set work over a raw serde_json::Value keyed by code:

let value = laterite_settings::get(&pool, SiteSettings::CODE).await?;
laterite_settings::set(&pool, "acme.site", &value.unwrap_or_default()).await?;

Editing settings in the admin

To let an operator edit a model from the admin panel, register a SettingsItem describing where it appears and which fields to show, and pass it to the admin router. One generic screen lists every registered item grouped by category, and one generic form edits each, reading and writing the JSON value through get/set. No per-model controller is needed.

use laterite_admin::settings::{SettingsField, SettingsItem};

let site = SettingsItem {
    code: SiteSettings::CODE.to_string(),
    label: "Site".to_string(),
    description: "Public site title, tagline, and a maintenance switch.".to_string(),
    category: "General".to_string(),
    order: 10,
    icon: Some("sliders-horizontal".to_string()),
    permission: None,
    link: None,
    fields: vec![
        SettingsField::text("title", "Site title"),
        SettingsField::text("tagline", "Tagline"),
        SettingsField::switch("maintenance_mode", "Maintenance mode"),
    ],
};

let app = laterite_admin::router(
    auth,
    pool,
    Vec::new(),
    vec![site],
    Vec::new(),
    laterite_admin::AdminConfig::default(),
);

Fields carry a widget: SettingsField::text, ::textarea, or ::switch (a checkbox stored as a JSON boolean). Items sort by category, then order. The settings table comes from builtin_migrations() (above).

Set an item’s permission to a dotted string to hide it from operators who lack it; a None permission is always visible, and superusers see everything. Registered items render in a categorised context sidebar on the settings screens, with the open item highlighted. Give each item an icon (a Lucide name such as users or shield) for the sidebar; an unknown or None name falls back to a generic glyph.

The settings menu vs the main menu

The admin has two menus. The main menu (top nav) holds Dashboard, the application’s own sections, and Settings. The settings menu is the grouped index at /admin/settings. Administrative screens (backend users, roles, and the like) belong in the settings menu, not as top-level tabs.

A SettingsItem normally edits a settings model at /admin/settings/{code}. Set its link to place an existing screen (a resource list) in the settings menu instead of a form:

SettingsItem {
    code: "acme.pages".to_string(),
    label: "Pages".to_string(),
    description: "Manage site pages.".to_string(),
    category: "Content".to_string(),
    order: 10,
    icon: Some("folder".to_string()),
    permission: None,
    link: Some("/admin/pages".to_string()),
    fields: Vec::new(),
};

The framework registers its own Users and Roles this way, under a Users category.

A linked screen keeps the settings context sidebar. The framework derives the context from the link: any request whose path is the link or falls under it (its list, forms and sub-pages) renders the settings sidebar with that item active. Registering the item is the only step, and the sidebar tracks the same link it navigates to.

Evolving a model

Because a model is one JSON blob and every field is #[serde(default)]:

  • Adding a field needs no migration. Existing rows lack the key, so it deserializes to the field’s default until the operator saves again.
  • Removing a field needs no migration. The stale key in stored JSON is ignored on load.
  • Renaming a field is a data change, not a schema change. Treat it like any rename: read the old key, write the new one. Never reuse a CODE for an incompatible model.

Permissions

Permissions are dotted strings such as posts.approve or backend.manage_users. A descriptor declares the permission a screen requires, and the framework checks it against the signed-in operator. An operator’s permissions come from the roles assigned to them; a superuser passes every check regardless of the roles held.

Grants and wildcards

A role grants a set of permission strings. A grant matches in one of three ways:

  • An exact grant matches its own string: posts.approve grants posts.approve.
  • A trailing-wildcard grant covers a whole namespace: posts.* grants posts.approve and posts.tags.create, but not the bare posts.
  • The global wildcard * grants everything.

Superusers short-circuit the check, so they always pass even with no grants listed.

Where permissions apply

A permission string appears on the descriptors that carry a screen, and the framework enforces it in two places.

Resource routes

A Resource carries an optional permission. When set, every route the resource mounts (its list, and its create and edit forms) is gated: an operator who lacks the grant receives 403 Forbidden, while an unauthenticated request is sent to the login screen. A None permission leaves the resource open to any signed-in operator.

use laterite_admin::Resource;

let pages = Resource {
    base_path: "/admin/pages".to_string(),
    nav_label: "Pages".to_string(),
    list: pages_list_config(),
    form: Some(pages_form_config()),
    permission: Some("acme.manage_pages".to_string()),
};

Settings items

A settings item carries the same optional permission. It controls visibility: an item the operator lacks the grant for is hidden from the settings menu and its form cannot be opened. Because the framework’s built-in Users and Roles are settings items linking to resources, their menu entry and their routes are gated together by giving the item and the resource the same permission.

Built-in permissions

The framework’s own administrative screens are gated by these grants. Assign them to a role to let an operator manage backend accounts without making them a superuser:

PermissionGrants access to
backend.manage_usersThe backend users list.
backend.manage_rolesThe roles list and the role editor.

Roles and per-user overrides

Permissions resolve in two layers. Roles set the base: an operator holds every permission granted by any role assigned to them. A per-user override then refines that base for one operator, and takes precedence over their roles.

For a given permission, an operator is allowed it when:

  1. they are a superuser (always allowed), otherwise
  2. their override for that permission decides: an explicit deny refuses it and an explicit allow grants it, otherwise
  3. the role grants decide.

A denial always wins over an allow. Overrides are exact permission codes, so a denial can carve a single permission out of a wildcard role grant.

Assigning permissions to a role

The Roles screen (under Settings, gated by backend.manage_roles) is the role permission editor. Editing a role shows every registered permission as a checkbox, grouped under its heading, ticked for the permissions the role already holds. Saving stores the ticked set on the role, and every operator with that role gains those grants. Only registered permissions can be ticked, so a role never carries a permission the deployment has not declared.

Overriding permissions for one user

The Backend Users screen (gated by backend.manage_users) edits a single user’s overrides. Each registered permission has a three-state control:

  • Allow: grant it to this user regardless of their roles.
  • Inherit (the default): defer to the roles.
  • Deny: refuse it to this user even if a role grants it.

Two rules keep the screen safe:

  • A superuser already holds every permission, so the screen shows a note rather than the controls for them.
  • An operator can only change permissions they hold themselves. Controls for permissions they lack are shown locked, and saving never alters those, so the screen cannot grant access beyond the editing operator’s own.

Registering permissions

Register the permissions your application defines by passing them to laterite_admin::router, so they appear in the role editor alongside the framework’s. A Permission is a code, a human label, and the group it sorts under:

use laterite_admin::Permission;

let permissions = vec![
    Permission {
        code: "acme.publish_pages".to_string(),
        label: "Publish pages".to_string(),
        group: "Content".to_string(),
    },
    Permission {
        code: "acme.manage_media".to_string(),
        label: "Manage media".to_string(),
        group: "Content".to_string(),
    },
];

let app = laterite_admin::router(auth, pool, resources, settings, permissions, config);

Gate a screen on one of these by setting it as a resource’s permission (above), and it becomes assignable from the role editor.

Database Portability

Laterite runs on Postgres, MySQL, or SQLite, chosen by the connection URL in configuration. The data layer is built on sea-query (which renders SQL for the running backend) over sqlx::Any (which dials any of the three at runtime), so a migration or query is written once and works everywhere.

sqlx::Any speaks a common subset of column types across backends, and a few native types do not travel cleanly (a SQLite boolean column, Postgres arrays, or jsonb). Rather than make you learn those quirks, the framework polyfills them: you write natural Rust types, and the framework stores a portable representation and converts at the boundary. When you hit a new quirk, add a polyfill in the same spirit rather than pushing it onto callers.

The strata toolkit

Migrations stack the schema in layers, like rock strata, and strata is the one import that gathers everything a layer needs. You should not have to import each helper or remember which polyfill a type takes. use laterite_core::strata::*; brings in the whole migration and query toolkit at once, the Migration trait and its async_trait macro, the schema and query builders, the Db handle, and the polyfills (bool_col, AnyRowExt). A migration file starts with that single line. Better still, generate the file with the scaffolding command, lat make migration <name>, which writes the skeleton with strata already imported, so imports are never your concern.

Portable representations

You writeStored asHow
boolinteger (0/1)bool_col in migrations; bound and read as bool
DateTime<Utc>text (RFC 3339)fixed-precision so string ordering is chronological
JSON object / arraytextserde_json at the boundary
key / id / code columnvarchar(255)key_col (MySQL cannot index text)
any string readStringAnyRowExt::get_text (MySQL reports text as BLOB)

sqlx::Any has no unsigned column types, so the bind layer also maps unsigned integers to the next wider signed integer. You rarely write these yourself, but the query builder emits them for LIMIT and OFFSET, so pagination stays portable without any handling on your part.

Booleans

A native boolean column is not decodable through sqlx::Any on SQLite, so booleans are stored as 0/1 integers on every backend. The framework hides this in three places, so you keep using bool:

  • Schema: use laterite_core::bool_col instead of ColumnDef::new(..).boolean().

    use laterite_core::bool_col;
    
    Table::create()
        .table(Article::Table)
        .col(bool_col(Article::Published).not_null().default(0))
        // ...
  • Bind: a bool value binds as an integer automatically, so filters read naturally: .and_where(Expr::col(Article::Published).eq(true)).

  • Read: AnyRowExt::get_bool reads it back:

    use laterite_core::AnyRowExt;
    
    let published: bool = row.get_bool("published")?;

Ids and timestamps

Ids are bigint auto-increment primary keys the database assigns, so an insert never sets the id. Reading the new id back is not uniform (Postgres and SQLite support RETURNING; MySQL reports a last-insert-id), so use laterite_core::query::insert_returning_id, which handles both and hands you an i64. Timestamps are RFC 3339 text written at a fixed precision, so a WHERE expires_at > ? comparison orders correctly as a string, and convert to DateTime<Utc> at the query boundary.

JSON

Store an object or array as text and (de)serialize with serde_json. Only whole-value read/write is portable; do not rely on in-database JSON operators or indexes, which are Postgres-specific.

Strings, keys, and casts

Three MySQL quirks shape how strings are stored and read; the helpers below hide all of them.

  • Key columns are varchar, not text. MySQL cannot index a text column, so any column that is a primary key, unique key, foreign key, or part of an index must be a bounded string. Use laterite_core::key_col(name) (a varchar(255)) for ids, codes, tokens, and anything you index; use plain .text() only for unindexed prose.

  • Read strings with get_text, never try_get::<String>. MySQL reports a text column as BLOB through sqlx::Any, so a plain String decode fails there (and even a cast-to-string of a text column comes back BLOB). AnyRowExt::get_text(col) (and get_text_opt for a nullable column) decodes as String on Postgres and SQLite and falls back to a byte read on MySQL:

    use laterite_core::AnyRowExt;
    
    let title: String = row.get_text("title")?;
  • Cast to a string with text_cast. A descriptor-driven screen casts every selected column to a string so a value of any type reads back uniformly. MySQL casts to char, Postgres and SQLite to text, so name the target with laterite_core::query::text_cast(backend): Expr::col(c).cast_as(Alias::new(text_cast(db.backend))).

Idempotent inserts

To insert a row and ignore a duplicate (an “insert or nothing”), use laterite_core::query::on_conflict_ignore(keys) rather than OnConflict::columns(keys).do_nothing(): sea-query renders MySQL’s do_nothing as invalid SQL, so the helper expresses the same intent in a form valid on every backend.

Case sensitivity of text keys

MySQL’s default collation compares strings case- and trailing-space-insensitively, while Postgres and SQLite are exact. A unique text key such as a username or email therefore behaves differently per backend unless you normalise it. For any user-facing key you look up or enforce uniqueness on, canonicalise it (lower-case and trim) on both write and lookup, as the auth store does for usernames and emails, so Root and root resolve to one account everywhere.

Behaviour the helpers do not hide

A few differences are inherent and worth knowing:

  • SQLite foreign keys. SQLite enforces foreign keys only when PRAGMA foreign_keys = ON; sqlx sets this by default, so a foreign key rejects on SQLite as it does on Postgres and MySQL. Do not disable it.
  • varchar needs a length on MySQL. Use key_col (a bounded varchar) or .text(); a bare ColumnDef::string() (unbounded varchar) is a MySQL error.
  • DDL is transactional only on Postgres and SQLite. MySQL commits implicitly after each schema statement, so a migration that fails partway cannot be rolled back there. Keep each migration to one table or change where practical.

Writing a portable query

Never hand-write SQL with ? placeholders: Postgres expects $1, $2, so a raw ? query fails there. Always build through sea-query and the query helpers, which render both the SQL and the placeholders for the connection’s backend.

Build the statement with sea-query and run it through the query helpers, which render for the connection’s backend and bind values portably:

use laterite_core::strata::*;

let stmt = Query::select()
    .column(Article::Title)
    .from(Article::Table)
    .and_where(Expr::col(Article::Published).eq(true))
    .to_owned();
let (sql, values) = build(db.backend, &stmt);
let rows = bind_values(sqlx::query(&sql), values).fetch_all(&db.pool).await?;

db here is a laterite_core::Db: the connection pool paired with its backend. Passing it (rather than a bare pool) is what lets the query layer render SQL for the right database.

Static Site Generation

Laterite renders two faces of an application. The admin (from laterite-admin) is the private, server-rendered back office. The public face is rendered by laterite-web, whose first capability is static-site generation: an application renders its public pages to HTML at build time and writes them to a directory that any static host or CDN can serve.

This suits a marketing site, documentation, or a mostly-read content site: pages are known at build time, so there is nothing to run in production but a file server.

This is provided by the laterite-web crate.

[dependencies]
laterite-web = "0.1"

The mental model

laterite-web owns the file layout, not the templating. Your application turns data into an HTML String however it likes (Askama, or any renderer), and hands each page to a StaticSite. The crate writes the files, copies your assets, and generates a sitemap.xml and robots.txt.

Two types carry the whole flow:

  • Meta builds the shared <head> tags (title, description, canonical URL, and Open Graph / Twitter card) so every page is described and shareable the same way.
  • StaticSite collects rendered pages, copies static assets, and finishes by writing the sitemap and robots file.

A minimal generator

A generator is an ordinary binary. It renders each page, writes it under its URL path, copies the static/ directory, and finishes:

use laterite_web::{Meta, StaticSite};

fn render_home(head: &str) -> String {
    format!("<!doctype html><html><head>{head}</head><body><h1>Acme</h1></body></html>")
}

fn main() -> std::io::Result<()> {
    let base_url = "https://acme.example";

    let meta = Meta::new("Acme", "The Acme website.")
        .canonical(format!("{base_url}/"))
        .image(format!("{base_url}/static/card.png"));
    let home = render_home(&meta.head_tags());

    let mut site = StaticSite::new("dist", base_url)?;
    site.page("/", &home)?;
    site.assets("static", "static")?;
    site.finish()?;
    Ok(())
}

Run it with cargo run. The output lands in dist/, ready to deploy.

Clean URLs

StaticSite::page maps a URL path to a clean-URL file, so links have no .html suffix:

Path passed to pageFile writtenServed as
/dist/index.html/
/features/dist/features/index.html/features/
/get-starteddist/get-started/index.html/get-started/

Every path passed to page is also recorded for the sitemap.

Page metadata

Meta renders the <head> tags every page shares. All values are escaped, so titles and descriptions are safe to build from content:

let meta = Meta::new(title, description)
    .canonical(format!("{base_url}{path}"))
    .image(format!("{base_url}/static/img/card.png"));

// Embed the result inside your document's <head>.
let head = meta.head_tags();

canonical also becomes the Open Graph URL; image becomes the social-share image. Both are optional.

Assets, sitemap, and robots

assets(from, to) copies a directory of CSS, fonts, and images into the output, recursively. finish() writes a sitemap.xml listing every page you added and a robots.txt that points at it. Call finish() once, after the last page:

site.assets("static", "static")?;
site.finish()?;

Deploying

The output directory is plain files with no runtime, so any static host serves it. A typical setup builds the generator and publishes dist/. On a host that builds from a Git repository, a build command of cargo run and a publish directory of dist is enough; commit the source and let the host produce the output.

What stays on the server

Static generation covers pages whose content is known at build time. Anything that depends on the request (a form submission, a search box, per-user content, the admin itself) stays on the live server. Pre-render the content-facing pages, keep the interactive parts served, and decide the split per page. An application whose core is auth-gated or write-heavy is served by the live server; static generation is for its public, content-facing pages.

API Reference

The per-crate API documentation is generated by rustdoc from the source. Build it locally with:

cargo doc --workspace --no-deps --open

Every public type, function, and trait carries doc comments, so the generated reference is the authoritative description of each crate’s surface. Once the crates are published, the same documentation is available on docs.rs.