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:
| Crate | Concern |
|---|---|
laterite-core | Module registration and the migration runner |
laterite-auth | Backend users, roles, sessions, and permissions |
laterite-admin | Descriptor-driven list and form screens |
laterite-settings | Typed settings models stored as JSONB |
laterite-cli | Administrative 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:
default.toml(required): the base configuration.<APP_ENV>.toml(optional): environment-specific overrides.APP_ENVselects the file and defaults todevelopment. Createstaging.toml,production.toml,testing.toml, and so on.local.toml(optional): personal developer overrides, kept out of version control.- 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.timezoneis 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:
systemfdbinds the listening socket once and passes it to each new build of your server.watchexecreruns 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:
- The signed-in operator’s own preference, if they have set one.
- Otherwise the deployment default from
backend.timezone(an IANA name such asAsia/Kolkata), which itself falls back toUTC.
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
CODEfor 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.approvegrantsposts.approve. - A trailing-wildcard grant covers a whole namespace:
posts.*grantsposts.approveandposts.tags.create, but not the bareposts. - 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:
| Permission | Grants access to |
|---|---|
backend.manage_users | The backend users list. |
backend.manage_roles | The 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:
- they are a superuser (always allowed), otherwise
- their override for that permission decides: an explicit deny refuses it and an explicit allow grants it, otherwise
- 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 write | Stored as | How |
|---|---|---|
bool | integer (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 / array | text | serde_json at the boundary |
| key / id / code column | varchar(255) | key_col (MySQL cannot index text) |
| any string read | String | AnyRowExt::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_colinstead ofColumnDef::new(..).boolean().use laterite_core::bool_col; Table::create() .table(Article::Table) .col(bool_col(Article::Published).not_null().default(0)) // ... -
Bind: a
boolvalue binds as an integer automatically, so filters read naturally:.and_where(Expr::col(Article::Published).eq(true)). -
Read:
AnyRowExt::get_boolreads 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, nottext. MySQL cannot index atextcolumn, so any column that is a primary key, unique key, foreign key, or part of an index must be a bounded string. Uselaterite_core::key_col(name)(avarchar(255)) for ids, codes, tokens, and anything you index; use plain.text()only for unindexed prose. -
Read strings with
get_text, nevertry_get::<String>. MySQL reports atextcolumn asBLOBthroughsqlx::Any, so a plainStringdecode fails there (and even a cast-to-string of atextcolumn comes backBLOB).AnyRowExt::get_text(col)(andget_text_optfor a nullable column) decodes asStringon 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 tochar, Postgres and SQLite totext, so name the target withlaterite_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;sqlxsets this by default, so a foreign key rejects on SQLite as it does on Postgres and MySQL. Do not disable it. varcharneeds a length on MySQL. Usekey_col(a boundedvarchar) or.text(); a bareColumnDef::string()(unboundedvarchar) 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:
Metabuilds the shared<head>tags (title, description, canonical URL, and Open Graph / Twitter card) so every page is described and shareable the same way.StaticSitecollects 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 page | File written | Served as |
|---|---|---|
/ | dist/index.html | / |
/features/ | dist/features/index.html | /features/ |
/get-started | dist/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.