| name | rust-saas |
| description | Build high-performance, low-cost, self-hosted Rust SaaS and web applications with Axum, SurrealDB v3, Askama templates, and Datastar hypermedia reactivity. Use this skill when building any Rust web application, server-side rendered app, or SaaS product — especially when the user mentions Axum, SurrealDB, Askama, Datastar, SSR, self-hosted, or wants a production-ready Rust web stack with authentication, graph-first data modeling, and Docker deployment.
|
| license | MIT |
| compatibility | Requires Rust 2024+ edition, Docker, and SurrealDB v3+. Optionally uses RustFS/MinIO for object storage. |
| metadata | {"author":"chrisabruce","version":"1.1.0","category":"rust-web-development"} |
Rust SaaS
You are an expert in building high-performance, low-cost-to-operate, self-hosted Rust SaaS and web applications. Every decision prioritizes correctness, security, idiomatic Rust, and operational simplicity.
Always apply the html-purist skill for all HTML output — semantic markup, CSS-first design, no inline styles, SSR-first, Datastar for interactivity.
Always apply the frontend-design skill for any visual or layout work — produces distinctive, modern, production-grade UI rather than generic templates. Pair it with html-purist so the markup stays semantic and the styling stays in CSS.
Always apply the surrealdb skill for any data-modeling, query, or schema work — it is the source of truth for SurrealDB v3 idioms.
Stack
| Layer | Technology |
|---|
| Language | Rust (edition 2024, always) |
| Web framework | Axum (latest) |
| Database | SurrealDB v3+ (server) |
| Database SDK | surrealdb Rust crate v3+ (matching the server major version) |
| Templating | Askama |
| Reactivity | Datastar (hypermedia, SSE-driven) |
| Async runtime | Tokio |
| Object storage | RustFS (S3-compatible, only when needed) |
| Authentication | JWT (jsonwebtoken) + Argon2 |
| CSS | Minimal, semantic, CSS custom properties — no frameworks |
| Number formatting | num-format / thousands (commas, 1.2k, 1M) |
| Date formatting | chrono-humanize / timeago ("24 hours ago", "in 5 minutes") |
| File sizes | humansize |
| Durations | humantime |
Server/SDK version lock-step: SurrealDB v3 servers must be paired with the v3 Rust SDK. Do not use the v2 surrealdb crate against a v3 server — APIs, error types, and RecordId semantics differ. If you see surrealdb = "1" or "2" in Cargo.toml against a v3 server, upgrade before doing anything else.
Core Principles
- Server-side rendering first — Askama templates render HTML on the server. No client-side frameworks.
- Datastar for reactivity — All dynamic behavior uses Datastar
data-* attributes. Server returns HTML fragments via SSE. Zero custom JavaScript files.
- Minimal CSS — CSS custom properties for theming, semantic class names only, no utility frameworks. One stylesheet preferred.
- Minimal JavaScript — Datastar is the only JS dependency. If HTML+CSS can do it (
<details>, <dialog>, :target, scroll-snap), use HTML+CSS.
- Idiomatic Rust, edition 2024 — see Idiomatic Rust. Match over nested
if, iterators over manual for loops, the type system over runtime checks, ? with thiserror/anyhow over .unwrap().
- Don't reinvent the standard library — see Reach for crates, not custom code. Things like Unicode segmentation, slug generation, percent-encoding, time math, and number formatting all have well-maintained crates. Custom one-off implementations are almost always wrong.
- Security by default — Run the security checklist after every code change.
- Graph-first data modeling — SurrealDB
RELATE, ->, <- for any real-world relationship. Edges with attributes (role, joined_at, weight, scope) model SaaS domains better than join tables in a relational DB. See Graph-first data modeling.
- Schemaful data — All SurrealDB tables
SCHEMAFULL. Concrete typed structs over serde_json::Value / flexible shapes. Use flexible storage only when the shape is genuinely unknown until runtime (e.g. user-defined custom fields), and document the reason in a comment.
- SurrealDB v3 SDK only —
surrealdb Rust crate v3+ against a v3 server. Use surrealdb::RecordId (the SDK type) for every record id. Never parse, split, or join id strings; never fall back to Thing (v2 name) or String-typed ids.
- Humanize numbers, dates, sizes, durations in any UI output — see Humanized output. Raw
1734892 and 2026-05-08T14:32:11Z are user-hostile.
cargo fmt + cargo clippy after every change — non-negotiable. See Quality gate.
- Use the
frontend-design skill for visuals — every page should look distinctive and modern, not like a Bootstrap demo from 2014.
Idiomatic Rust
Always edition 2024 (edition = "2024" in Cargo.toml). Code should read as plainly as English; if a future reader has to squint, simplify.
- Match over nested
if / if let when branching on a value. match is exhaustive, lays out cases vertically, and the compiler tells you when a new variant is missing.
match user.role {
Role::Owner => grant_full_access(&user),
Role::Admin => grant_admin_access(&user),
Role::Member => grant_read_access(&user),
}
if user.role == Role::Owner {
grant_full_access(&user);
} else if user.role == Role::Admin {
grant_admin_access(&user);
} else if user.role == Role::Member {
grant_read_access(&user);
}
- Iterators over manual
for loops when transforming, filtering, or reducing. Manual for-with-Vec::push is almost always a map/filter/collect. Reach for for only when you need side effects, early break, or want to be explicit about ordering.
let active_emails: Vec<String> = users
.iter()
.filter(|u| u.active)
.map(|u| u.email.clone())
.collect();
let mut active_emails = Vec::new();
for u in &users {
if u.active {
active_emails.push(u.email.clone());
}
}
- Type system over runtime checks. Newtypes (
UserId(RecordId), Email(String)), enums for finite states (Role, Status), and NonZeroUsize / NonEmpty<T> for invariants. If the compiler can rule it out, it should.
? with thiserror (libraries, domain errors) and anyhow (binaries, top-level handlers). No .unwrap() / .expect() on anything that can fail in production code. Tests and one-shot setup are fine.
- Borrow first, clone deliberately.
&str over String, &[T] over Vec<T> in function signatures. Clone when you actually need ownership, not as a panic-shield.
- Small functions, named intermediates. A let binding with a good name beats a comment.
When refactoring existing code, the rust-refactor-pro and rust-best-practices skills are the source of truth.
Reach for crates, not custom code
If a problem looks like "string handling, but tricky" or "date math" or "format a number", a crate already solved it correctly. Custom one-off implementations of these are a frequent source of bugs (off-by-one in Unicode segmentation, DST math, locale-aware grouping, etc.).
Before writing custom code, check:
std — str::split, str::trim, slice::chunks, Iterator adapters cover most cases.
itertools — group_by, chunks, dedup, cartesian_product, etc.
unicode-segmentation — grapheme/word/sentence boundaries. Never hand-roll Unicode iteration.
regex — pattern matching beyond str::contains.
url / percent-encoding — URL parsing and escaping.
slug — URL slug generation.
serde_json / serde_urlencoded — never hand-parse.
uuid (v7 for time-sortable ids) and surrealdb::RecordId for SurrealDB ids.
chrono + chrono-humanize / timeago for time math and humanization.
num-format / thousands for 1,234,567 style grouping.
humansize for bytes ("1.4 MiB"), humantime for Durations.
Rule of thumb: if you're about to write a function that loops over chars, splits on punctuation, or does timezone arithmetic, stop and search crates.io first.
Graph-first data modeling
SurrealDB models real-world relationships better than a relational DB ever could — use that. For SaaS domains (users, teams, orgs, projects, permissions, invitations, audit trails), the graph is almost always the right shape.
- Prefer
RELATE and edge tables over foreign-key columns. user ->member_of-> team reads better than user.team_id and gives you many-to-many for free.
- Put attributes on the edge when the relationship has its own state.
role, joined_at, invited_by, permissions, weight, last_active_at all belong on member_of, not duplicated on either endpoint.
- Multi-purpose edges. A single
member_of edge with a role field replaces three separate is_owner_of / is_admin_of / is_member_of tables.
- Traversal queries (
SELECT ->member_of->team->has_resource->resource.* FROM $user) replace multi-join SQL — keep them in .surql files or constants, never built by string concatenation.
- Schemafull edges too.
DEFINE TABLE member_of SCHEMAFULL TYPE RELATION FROM user TO team; with ASSERT-checked fields.
See references/surrealdb-patterns.md for full examples.
Humanized output
UIs are read by humans. Surface human-friendly formats by default; keep raw values for APIs and data-* attributes if they're needed for sorting/scripting.
| What | Crate | Example |
|---|
| Integers / counts | num-format or thousands | 1234567 → "1,234,567" |
| Compact counts | numformat / custom helper using num-format | 1234 → "1.2k", 2_400_000 → "2.4M" |
| Bytes | humansize | 1_572_864 → "1.5 MiB" |
| Relative time | chrono-humanize or timeago | now - 24h → "a day ago", now + 5min → "in 5 minutes" |
| Absolute time | chrono format | use locale-aware tokens, never hand-format |
| Durations | humantime | Duration::from_secs(125) → "2m 5s" |
Expose these as Askama filters (e.g. {{ user.created_at | timeago }}, {{ usage.bytes | bytes }}, {{ count | thousands }}) so templates stay tidy and the formatting decision lives in one place.
For accessibility, pair humanized text with the precise value in <time datetime="..."> or title="...":
<time datetime="{{ post.created_at }}" title="{{ post.created_at }}">
{{ post.created_at | timeago }}
</time>
Quality gate
Run after every code change, no exceptions:
cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test
rustfmt.toml checked in (or use defaults). Don't argue with fmt.
cargo clippy with -D warnings — clippy lints are treated as errors. Fix the cause; only #[allow(...)] with a comment explaining why.
- Wire these into
make check and a pre-commit hook.
cargo audit and cargo deny check in CI.
Project Structure
project-root/
├── Cargo.toml
├── Makefile
├── Dockerfile
├── docker-compose.yml
├── .env-example # Committed — .env is gitignored
├── .gitignore
├── db/
│ ├── schema.surql # Always reflects current state
│ ├── migrations/ # Versioned, numbered migrations
│ └── seed.surql # Optional seed data
├── src/
│ ├── main.rs
│ ├── config.rs # Env/config loading via dotenv
│ ├── router.rs # Route definitions
│ ├── error.rs # AppError types
│ ├── auth/
│ │ ├── mod.rs
│ │ ├── middleware.rs # JWT extraction/validation
│ │ ├── claims.rs # JWT claims
│ │ └── password.rs # Argon2 hashing
│ ├── models/ # SurrealDB record structs
│ ├── controllers/ # Axum handlers
│ ├── db/ # Connection, migration runner
│ └── templates/
│ ├── base.html # Askama base with OG tags
│ ├── pages/ # Full page templates
│ ├── partials/ # Datastar fragment targets
│ └── components/ # Reusable includes
├── static/
│ ├── css/main.css # Single stylesheet preferred
│ └── images/
└── tests/
└── common/mod.rs # In-memory SurrealDB setup
No static/js/ directory. Datastar is loaded via CDN <script> tag.
Required Crates
[dependencies]
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
surrealdb = "3"
askama = "0.13"
askama_axum = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
argon2 = "0.5"
chrono = { version = "0.4", features = ["serde"] }
dotenv = "0.15"
anyhow = "1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
uuid = { version = "1", features = ["v7", "serde"] }
futures = "0.3"
sysinfo = "0.33"
humansize = "2"
humantime = "2"
num-format = "0.4"
[dev-dependencies]
surrealdb = { version = "3", features = ["kv-mem"] }
tokio = { version = "1", features = ["full", "test-util"] }
Always look for an existing crate before writing custom formatting. Use env!("CARGO_PKG_VERSION") for version reporting. Run cargo audit regularly.
Routing Conventions
Standard REST routes. All return server-rendered HTML. Fragment endpoints for Datastar use SSE responses.
GET /resources # Index
GET /resources/new # New form
POST /resources # Create (SSE fragment)
GET /resources/:id # Show
GET /resources/:id/edit # Edit form (SSE fragment)
PUT /resources/:id # Update (SSE fragment)
DELETE /resources/:id # Delete (SSE fragment)
GET /partials/... # Fragment-only endpoints
GET /healthcheck # Always present
Datastar Reactivity
The server owns all state. Datastar connects user actions to server endpoints returning HTML fragments via SSE.
Pattern: User action → data-on-* → HTTP request → Axum handler → Askama partial → SSE fragment → DOM swap.
See references/datastar-patterns.md for complete Datastar integration patterns including SSE helpers, fragment responses, forms, inline editing, and live updates.
Key rules:
- Zero custom JS files — Datastar
data-* attributes only
- Server returns HTML fragments, never JSON for UI
- SSE for all dynamic responses
- Progressive enhancement — pages work without JS
- Use HTML+CSS first (
<details>, <dialog>, :hover) — Datastar only when server interaction needed
Models
Use the SurrealDB v3 SDK RecordId type directly — never manually split, parse, or construct ids with string operations, and never use Thing (v2 name) or String for an id field.
use surrealdb::RecordId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: RecordId,
pub email: String,
pub name: String,
pub password_hash: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
Constructing a RecordId from a known table + key is built into the SDK — use it instead of formatting strings:
let id = RecordId::from_table_key("user", user_id);
let id_str = format!("user:{}", user_id);
let (table, key) = id_str.split_once(':').unwrap();
- Separate structs for create, update, and view operations
- Never expose
password_hash in view structs
- Always include
created_at and updated_at
- Prefer concrete typed fields over
serde_json::Value / BTreeMap<String, Value>. Only use a flexible shape when the schema is genuinely user-defined at runtime, and add a comment explaining why.
Controllers
Async Axum handlers. Validate input, verify ownership, render templates or SSE fragments.
pub async fn show(
State(state): State<AppState>,
claims: Claims,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let record_id = RecordId::from_table_key("resource", id);
let resource: Option<Resource> = state.db.select(record_id).await?;
let resource = resource.ok_or(AppError::NotFound)?;
if resource.owner != claims.user_id() {
return Err(AppError::Forbidden);
}
Ok(templates::pages::ResourceShow { resource })
}
- Always extract
Claims for authenticated routes
- Always verify resource ownership — prevent ID manipulation
- Return
AppError, never panic
- Full page loads use base template; Datastar actions return SSE fragments
SurrealDB
All tables SCHEMAFULL. Graph relations over foreign keys. Parameterized queries only.
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD email ON user TYPE string ASSERT string::is::email($value);
DEFINE FIELD name ON user TYPE string ASSERT string::len($value) >= 1;
DEFINE FIELD password_hash ON user TYPE string;
DEFINE FIELD created_at ON user TYPE datetime DEFAULT time::now();
DEFINE FIELD updated_at ON user TYPE datetime DEFAULT time::now() VALUE time::now();
DEFINE INDEX idx_user_email ON user FIELDS email UNIQUE;
-- Graph relation with data on the edge
DEFINE TABLE member_of SCHEMAFULL TYPE RELATION FROM user TO team;
DEFINE FIELD role ON member_of TYPE string ASSERT $value IN ["owner", "admin", "member"];
DEFINE FIELD joined_at ON member_of TYPE datetime DEFAULT time::now();
-- Graph traversals
SELECT ->member_of->team.* FROM user:abc;
SELECT <-member_of<-user.*, <-member_of.role FROM team:xyz;
See references/surrealdb-patterns.md for migrations, testing, and gotchas.
Gotchas:
- Server and SDK must match major version. A v3 server with the v2
surrealdb crate will fail in subtle ways (different RecordId serialization, different error types). Pin surrealdb = "3" and re-check after every dependency bump.
- Use
surrealdb::RecordId — the v3 top-level re-export — never surrealdb::sql::Thing (v2) or hand-built strings.
- When SurrealDB behaves unexpectedly, check GitHub issues before assuming your code is wrong — v3 evolves quickly.
- Track applied migrations in a
_migrations table with unique index.
Authentication
JWT + Argon2. See references/auth-patterns.md for full implementation.
- Login → Argon2 verify → issue JWT
- JWT via
Authorization: Bearer header or HTTP-only cookie
- Axum extractor validates on every authenticated request
- Claims contain user
sub (RecordId as string) + exp + iat
Security
Run references/security-checklist.md after every code change.
Critical rules:
- Parameterized SurrealQL queries — never string interpolation
- Askama auto-escaping — never use
|safe on user content
- Verify resource ownership in every controller (including fragment endpoints)
.env in .gitignore, .env-example committed
- Security headers: CSP (allow Datastar CDN), HSTS, X-Frame-Options, nosniff
- Rate limiting on auth endpoints
- Argon2 for passwords, JWT with reasonable expiry
Healthcheck
Every app must have GET /healthcheck returning JSON with:
version (from CARGO_PKG_VERSION)
status
system (memory, CPU, disk via sysinfo crate)
services (database connectivity, object storage if used)
Open Graph
Every page template must include OG tags via Askama base template. See references/templates.md for the base template with OG, Twitter Card, and JSON-LD.
Logging
tracing + tracing-subscriber. Pretty for dev (PRETTY_LOGS=true), JSON for production. Never log secrets. Use structured fields. Middleware-level request logging via tower_http::trace.
Makefile Targets
Required targets: dev (cargo watch), services (docker compose up), build (Docker build), db-init, db-drop, db-seed, test, check (runs cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings && cargo test), fmt (cargo fmt --all), lint (cargo clippy --all-targets --all-features -- -D warnings).
Dockerfile
Multi-stage: rust:bookworm builder with build-essential, pkg-config, libssl-dev → gcr.io/distroless/cc-debian12 runtime. Cache dependency builds. See references/docker.md.
.gitignore
Must ignore: /target/, .env, surreal-data/, *.db, rustfs-data/, minio-data/. Commit Cargo.lock for binaries.
Testing
All tests use in-memory SurrealDB (kv-mem feature). Never hit a running instance. Test authorization (users can't access others' data), input validation, graph traversals, and SSE fragment responses. Use #[tokio::test].