Building a Unified Multi-Tenant SSR Engine in Rust
From a Single Binary to a White-Label SaaS Platform
Summary
Over a concentrated development sprint (June 15–20, 2026), I designed and shipped a full multi-tenant Server-Side Rendering engine on top of mop_dev, an existing Rust/Axum monolith that was already handling several distinct hosted properties. The expansion turned the platform into a white-label SaaS capable of serving an unlimited number of independent small-business websites — each with its own domain, branding, page layout, product catalog, custom forms, and integrations — all from the same binary, without a new service or a new database. This article documents the architecture, the key engineering decisions, and how each layer was built.
1. Starting Point: One Binary, Multiple Properties
mop_dev is a monolith. A single compiled Axum process handles several properties — the MOP marketing site, API, auth, CDN, and payment — by dispatching on the Host header through a HostSwitchService, a custom tower::Service implementation that acts as a router of routers:
impl Service<Request<Body>> for HostSwitchService {
fn call(&mut self, req: Request<Body>) -> Self::Future {
let hostname = Self::extract_hostname(&req);
// Static registry first (marketing, api, cdn…)
if let Some(router) = registry.get(&hostname) {
return router.clone().oneshot(req);
}
// Dynamic path: proxy_routes table or presentation tenant?
// Resolved from a TTL cache, falling back to a DB query.
...
}
}
The static registry maps a fixed set of known hostnames (e.g. mop.dev, api.mop.dev) to pre-built Axum routers. Everything else hits the dynamic path. Before this expansion, an unknown host either matched a proxy_routes row (reverse-proxied to an upstream) or returned 404. The goal was to make it resolve to a third option: a fully server-rendered tenant site.
1.1 The Dynamic Resolution Cache
Both the host router and the presentation layer use a pattern I've now standardized across the codebase: a TTL-bounded in-memory cache with negative entries. The host router's cache:
- Holds up to
1,024entries (bounds memory against host-spoofing attacks) - Caches
None(negative) for non-tenant hosts so repeated requests for unknown hostnames never re-query the database - Expires entries after 30 seconds, so admin edits propagate within half a minute
- Stores a cloned
Routervalue, meaning upstreamreqwest::Clientconnection pools are reused across requests (a per-request client construction was the previous behavior — a real performance problem on burst traffic)
The presentation layer adds its own second-level cache (cache.rs) operating at a 60-second TTL specifically for SiteConfig objects, so the full DB assembly cost (site row + pages + products + forms) is paid at most once per minute per tenant host.
2. The Block Layout Data Model
The engine centers on a SiteConfig struct that is the single source of truth shared across the backend, the SSR renderer, and the client-side Yew application. It lives in mop_dev_shared — a crate that compiles for both native (backend) and WebAssembly (frontend) targets.
pub struct SiteConfig {
pub host: String,
pub business_name: String,
pub contact_email: String,
pub address: Option<String>,
pub phone: Option<String>,
pub vat_number: Option<String>,
pub theme_selector_enabled: bool,
pub theme_config: ThemeConfig,
pub favicon_url: Option<String>,
pub og_image_url: Option<String>,
pub pages: Vec<SitePage>,
pub products: Vec<Product>,
pub forms: Vec<FormDefinition>,
}
Each tenant's site is a list of SitePage values, each holding an ordered list of Block variants. The Block enum uses serde's internal tag representation so page layouts serialize to clean, readable JSONB arrays stored in Postgres:
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum Block {
Hero { title: LocalizedText, subtitle: LocalizedText },
About { text: LocalizedText },
Features { heading: LocalizedText, items: Vec<Feature> },
Gallery { heading: LocalizedText, images: Vec<GalleryImage> },
ContactForm { heading: LocalizedText },
Catalog { heading: LocalizedText },
CustomForm { form_id: String, heading: LocalizedText },
Cta { title: LocalizedText, subtitle: LocalizedText, button_label: LocalizedText, button_url: String },
}
All user-facing copy is stored as LocalizedText, a HashMap<String, String> keyed by language code. The resolution function is pure and identical on both sides of the SSR boundary:
pub fn get_localized_text(field: &LocalizedText, lang: &str) -> String {
field.get(lang).or_else(|| field.get("en")).cloned().unwrap_or_default()
}
This guarantees the server-rendered HTML and the hydration render produce bit-identical text nodes.
3. The SSR Pipeline
3.1 Request Flow
When a request arrives for a tenant host, ssr_render_handler (in infra/router.rs) does the following:
- Resolve the tenant. Call
presentation::cache::get_or_load(&state.db, &host). ReturnsSome(SiteConfig)for a registered tenant,Nonefor a marketing/unknown host. - Build per-request head signals. If it's a tenant, call
apply_presentation_head(injects title, canonical URL, OpenGraph tags, theme CSS custom properties, manifest link, favicon, service worker registration script). If it's the marketing site, callapply_seo. - Determine HTTP status. A tenant page with no matching slug returns a real
404, not a soft 404 — a200with a "not found" body would pollute the search index. - Server-render into the shell. Call
render_route_ssr(url, auth_token, host, site, article), which runs the Yew component tree synchronously (WASM on the server side). The rendered HTML string replaces the<div id="app">mount point in the shell template. - Set edge-cache headers. Presentation tenant
200responses getpublic, max-age=0, s-maxage=300, stale-while-revalidate=86400plus a per-hostCache-Tagheader, enabling Cloudflare to serve and purge them at the edge.
3.2 Hydration Safety
The SiteConfig travels from server to client by being serialized into a <script id="site-config" type="application/json"> element inside the SSR'd HTML. The Yew client app reads it back with serde_json::from_str during its mount. Because both sides deserialize the same struct definition from the same shared crate, the virtual DOM trees produced by the server render and the first client render are structurally identical — Yew's hydration diffing finds no mismatches and does not re-render.
This is why the Catalog block loads products only when a page actually declares it:
// repository.rs
let has_catalog = pages.iter().any(|p| p.blocks.iter().any(|b| matches!(b, Block::Catalog { .. })));
let products = if has_catalog {
// one query, scoped to active products for this site_id
...
} else {
Vec::new()
};
Shipping an empty products array for sites with no catalog block keeps the #site-config payload small and avoids a needless database round-trip. The same conditional-load pattern applies to CustomForm blocks: the form definitions for only the referenced form IDs are fetched, and only when at least one CustomForm block exists on the site.
3.3 The apply_presentation_head Transform
This function is responsible for turning the generic app shell (index.html) into a white-labeled tenant page before SSR runs. Key injections it makes:
<title>— resolved per-page, with the business name appended.- Canonical URL — built from
https://{host}{path}. - OpenGraph tags —
og:title,og:description,og:image(fromog_image_urlor omitted for white-label cleanliness),og:type,og:site_name. - Theme CSS custom properties — a
<style>block setting--pres-primary,--pres-secondary, and--pres-font-familyfrom the tenant'sThemeConfig. The entire presentation stylesheet (presentation.css) uses only these properties; no tenant-specific CSS file is ever generated. - Manifest link + PWA meta —
<link rel="manifest" href="/manifest.webmanifest">, Apple touch icon, mobileviewportandtheme-colormeta tags. - Service worker registration — the
HOSTSCRIPTSplaceholder in the shell (which normally holds the MOP analytics and chat embed) is stripped entirely for tenant pages and replaced with a minimal inline script that registers/sw-tenant.js. noindexfor/portalpaths — the tenant admin area is excluded from crawling.
All of this happens through string replacement on the static index.html template — no templating engine, no additional dependencies.
4. The Repository Layer
repository.rs implements load_site_config, which assembles the SiteConfig for a given (normalized) hostname in three sequential queries. It uses sqlx's runtime query_as API rather than the compile-time query! macro, deliberately keeping these queries out of the .sqlx offline cache — the rest of the codebase builds with SQLX_OFFLINE=true, and a mandatory DB connection at compile time was unacceptable:
// 1. Site row
let site = sqlx::query_as::<_, SiteRow>(
"SELECT id, business_name, contact_email, address, phone, vat_number, \
theme_selector_enabled, theme_config, favicon_url, og_image_url \
FROM presentation_sites WHERE host = $1 AND is_active = TRUE",
).bind(host).fetch_optional(db).await?;
// 2. Pages (always)
let pages = sqlx::query_as::<_, PageRow>(
"SELECT slug, title, blocks FROM site_pages WHERE site_id = $1 ORDER BY slug",
).bind(site.id).fetch_all(db).await?;
// 3. Products (only if a Catalog block exists)
// 4. Forms (only the IDs referenced by CustomForm blocks)
ThemeConfig and Vec<Block> are stored as JSONB in Postgres and deserialize transparently via sqlx::types::Json<T>, which is a thin wrapper that calls serde_json during row mapping — no manual parsing.
5. IDOR-Safe Ownership: The OwnedResource Extractor
The tenant portal (the business owner's admin area) is built around an Axum extractor that enforces ownership before any handler code runs. The trait:
pub trait OwnedResourceLookup: Sized {
async fn fetch(state: &AppState, id: Uuid, access: ResourceAccess) -> AppResult<Option<Self>>;
}
The OwnedSite implementation shows the pattern:
impl OwnedResourceLookup for OwnedSite {
async fn fetch(state: &AppState, id: Uuid, access: ResourceAccess) -> AppResult<Option<Self>> {
sqlx::query_as::<_, OwnedSite>(
"SELECT ... FROM presentation_sites
WHERE id = $1 AND ($3 OR owner_user_id = $2)",
)
.bind(id)
.bind(access.user_id)
.bind(access.is_admin)
.fetch_optional(&state.db)
.await
}
}
The ($3 OR owner_user_id = $2) clause means:
- A regular owner only reaches rows where
owner_user_idmatches their session. - A platform admin (
is_admin = true) bypasses the filter and sees all spokes. - A non-owner guessing a site UUID gets
Noneback — indistinguishable from "does not exist."
This IDOR defense is applied at every ownership boundary: OwnedProduct for catalog items, OwnedForm for custom forms, and their child resources (pricing rules, availability rules, form submissions). No handler in the portal can be reached with a resource that hasn't already been proven to belong to the caller.
6. The Product Catalog
6.1 Data Model
Products are a unified model covering retail goods, food items, and bookable services:
pub enum ProductType { Retail, Food, Service }
pub enum ProductStatus { Active, Hidden, Draft }
pub struct Product {
pub id: String,
pub product_type: ProductType,
pub name: LocalizedText, // {"en": "...", "it": "..."}
pub description: LocalizedText,
pub price_minor: i64, // integer cents — never f64
pub currency: String, // ISO-4217
pub status: ProductStatus,
pub tags: Vec<String>,
pub image_url: Option<String>,
pub sort_order: i32,
}
Money is stored exclusively as integer minor units. The display helper:
pub fn format_minor(minor: i64) -> String {
let abs = minor.unsigned_abs();
format!("{}{}.{:02}", if minor < 0 { "-" } else { "" }, abs / 100, abs % 100)
}
Both name and description are LocalizedText stored as JSONB, making them first-class targets for the mop_cli tscan --db translation workflow.
All enum variants have explicit as_str() / from_db() conversions with safe fallbacks (from_db("bogus") == Retail), making schema migrations forward-compatible: a row written by a newer version of the app is never a parse failure in an older version still running.
6.2 Pricing Rules Engine
Phase 2b added a pure-function pricing engine in catalog.rs. Rules are time-windowed and evaluated first-match-wins:
pub fn effective_price_minor(base_minor: i64, rules: &[PricingRule], ctx: TimeContext) -> i64 {
rules.iter()
.filter(|r| r.active)
.find(|r| window_matches(&r.days_of_week, r.start_minute, r.end_minute, ctx))
.map(|r| apply_modifier(base_minor, r))
.unwrap_or(base_minor)
}
The window_matches function handles three semantically distinct cases cleanly:
daysempty → every daystart == end→ all daystart > end→ overnight window (e.g. 22:00–02:00 wraps across midnight)
The three modifier types (PercentageDecrease, PercentageIncrease, AbsoluteOverride) all clamp output to ≥ 0, so no pricing rule can ever produce a negative price. The availability engine uses OR semantics: no active rules means always available; once rules exist, the product is available iff at least one active rule's window matches the current time.
The entire engine is pure (no I/O, no side effects) and covered by a comprehensive test suite including edge cases for overnight windows, surge pricing, inactive rule skipping, all-day windows, and clamp behavior.
7. Custom Forms
The site_forms table stores form definitions as JSONB fields arrays:
pub enum FieldType { Text, Email, Tel, Number, Textarea, Select }
pub struct FormField {
pub key: String,
pub label: LocalizedText,
pub field_type: FieldType,
pub required: bool,
pub options: Vec<String>, // for Select fields
}
The public form submission endpoint resolves the tenant by Host header (not a submitted ID — visitors never choose their recipient), filters the submitted payload to only declared field keys (preventing mass-assignment), enforces required fields, and delivers to either the form's notify_email or the site's contact_email. The submission is also persisted, making it queryable from the portal's "Moduli" tab.
8. Assisted Catalog Digitalization (Phase 6)
Phase 6 is the most operationally interesting addition. The problem: a small business owner may have their product catalog spread across photos of handwritten menus, PDF price lists, supplier invoices, and Word documents. Getting this into a structured database catalog is the real bottleneck — not building the software.
The solution is a human-in-the-loop digitalization pipeline with two surfaces:
Owner side: uploads raw files (multipart, any format, up to 30 MB) through the portal. A catalog_job is opened on first real file received — critically, the job creation is lazy:
// Job creation deferred until we have a real file in hand —
// a rejected oversized request or a no-file POST never starts an empty 48h SLA job.
let mut job_id: Option<Uuid> = None;
while let Some(field) = multipart.next_field().await? {
let bytes = field.bytes().await?;
if bytes.is_empty() { continue; }
// Only now, on the first real file:
let job_id = match job_id {
Some(id) => id,
None => {
let id = ensure_open_job(&state.db, site_id).await?;
job_id = Some(id);
id
}
};
// store blob, insert upload row…
}
This detail matters: before the fix, a rejected upload (file too large, multipart malformed) would still open a job and start a 48-hour SLA clock on an empty job — a real operational problem.
Raw files are stored on disk via a BlobStore trait abstraction (currently backed by a local volume on the Hetzner host, swappable for S3-compatible storage behind the same trait). The catalog_ingest_uploads table stores only metadata plus an opaque storage_key; blobs are never web-accessible.
Admin side: The /mop-backstage view shows a cross-spoke SLA queue sorted by due_at. The admin can:
-
Download a bundle ZIP (
GET /admin/presentation/ingest/sites/{id}/bundle.zip): streams a deflate-compressed ZIP built in memory containing all raw uploads pluscurrent_catalog.jsonandcurrent_catalog.csvfor that spoke. The CSV provides a human-readable reference of what's already live; the JSON is the exact format the re-import endpoint accepts. -
Import a structured catalog (
POST /admin/presentation/ingest/sites/{id}/import): accepts aCatalogImport(a list ofProductInputvalues) and stages them asdraftproducts — atomically replacing any prior drafts in a transaction. Thedraftstatus means nothing staged ever leaks to the public site. -
Apply or reject (
POST /admin/presentation/ingest/jobs/{id}/apply|reject): apply flipsdraft → activefor all staged products in a singleUPDATE, closes the job, and emails the business owner. Reject discards the drafts and records the reason. Both run inside a transaction.
Because draft is already a ProductStatus variant in the existing catalog model, Phase 6 added no schema changes to the products table — it reuses the three-state publication model that was already there.
9. White-Label PWA and Per-Host SEO
Every tenant host gets its own installable Progressive Web App and its own search-engine files — without any per-tenant static build.
PWA: /manifest.webmanifest is generated on the fly from the SiteConfig (business name, theme color, favicon URL):
fn tenant_manifest_json(config: &SiteConfig) -> String {
let short: String = config.business_name.chars().take(12).collect();
serde_json::json!({
"name": config.business_name,
"short_name": short,
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": config.theme_config.primary_color,
// icon array added when favicon_url is set
}).to_string()
}
/sw-tenant.js is a single constant — a minimal network-first shell cache. Origin-scoping means each subdomain registers its own independent worker instance. The service worker is force-served no-cache so browser updates are immediate.
SEO files: each tenant gets:
/robots.txt— open to all crawlers, pointing at its own sitemap (never the MOP marketing sitemap)./sitemap.xml— built from the site'spagesslugs with absolute tenant-host URLs./llms.txt— an AI crawler index with page titles and links, plus the sitemap reference.
All three are generated in-process by pure string-building functions; there is no file system write and no build step.
10. Opt-In Light/Dark Theme System
Presentation sites default to an always-light appearance (white background, tenant-supplied brand colors via --pres-* CSS custom properties). Optionally, a spoke can enable theme_selector_enabled, which:
- Renders the
ThemeTogglecomponent in the spoke header. - Adds the
.pres-site--themedCSS class to the site root, which activates a:root[data-theme="dark"] .pres-site--themedoverride block inpresentation.css— full dark-mode theming without any per-tenant CSS. - Seeds the initial theme from
prefers-color-schemeviawindow.matchMedia— scoped to themed spokes only, so the marketing site's fixed dark default is unaffected. - Persists the user's choice to
localStorageunder a per-host key.
The theme_selector_enabled flag travels in SiteConfig so the server render and the client render agree on whether to add the class — no hydration mismatch.
11. Google Business Profile Scaffolding
Phase 5 ships the complete data model and API surface for Google sync — without making a single live Google API call.
A site_google_connections table holds per-site connection state:
refresh_token_encrypted BYTEA -- post-quantum encrypted, same helper as OAuth login tokens
status -- not_connected | pending | connected | error
location_id -- the GBP locations/<id> resource name
last_synced_at TIMESTAMPTZ
last_error TEXT
The sync entry point is a single perform_outbound_sync function that currently guards on GOOGLE_GBP_CLIENT_ID being set in the environment and a stored refresh token being present — returning a clear diagnostic error when either is absent. This is the intentional seam: when the Google Cloud project is approved and credentials are available, only this function needs to be implemented (OAuth token refresh + reqwest calls to the Business Information API). The rest of the portal UI, the connection status card, the location-ID setter — all of that is already live.
The build log documents the full OAuth handshake pattern (access_type=offline + prompt=consent to guarantee a refresh_token), the two Google Cloud account models (a single platform-managed account vs. per-owner consent), and exactly which GBP API surfaces are actually available via a public API (services only — the retail product catalog and food menus have no general push API).
12. Test Architecture
The test suite is split across three scopes:
Pure unit tests (no DB): catalog.rs and forms.rs exercise the pricing engine, availability logic, money formatting, price parsing, and form validation in isolation — 39 tests in the shared crate, all runnable in CI without a database. These cover every edge case of the effective_price_minor and is_available functions, including overnight windows, surge modifiers, inactive rule skipping, clamp behavior, and OR semantics.
Backend unit tests: repository.rs, cache.rs, and ingest.rs contribute pure-logic tests (host normalization, ZIP assembly, CSV escaping) that also need no DB.
Integration tests (tests/presentation_tests.rs): 35 tests that spin up a real Postgres instance and exercise full HTTP round-trips through the Axum router — product CRUD lifecycle, validation 400s, create-IDOR probes, active-product appearance in SiteConfig, form CRUD, pricing rule create/list, availability gating via GET /products/{id}/price/current, and oversized-upload rejection (verifying that no job or upload row is written on a rejected request). IDOR tests explicitly prove that an owner cannot read or modify another owner's resources.
A defect discovered during this hardening pass: the ensure_open_job call in the upload handler was made before reading the multipart body, so a rejected oversized upload still opened an empty catalog_job and started its 48-hour SLA. The fix (lazy job creation) was validated by the oversized_upload_is_rejected_and_persists_nothing integration test.
13. Deployment Notes
The application runs as a systemd service (mop.service) on a Hetzner dedicated server — not a container. This matters for Phase 6's blob storage: the upload directory (/var/lib/mop_dev/uploads) is a real host path that survives deploys, which only swap /opt/mop/current. Under ProtectSystem=strict, the filesystem is read-only except for paths explicitly granted via ReadWritePaths / StateDirectory. The uploads directory is granted writability through a systemd drop-in that sets StateDirectory=mop_dev, causing systemd to create and own /var/lib/mop_dev at the correct permissions.
The wildcard *.mop.dev DNS record and TLS certificate mean adding a new tenant spoke requires only a database row insertion — no DNS change, no deploy, and the new site is live within the 60-second SiteConfig cache TTL.
Conclusion
The Unified SSR Engine extends mop_dev from a single-product platform into a multi-tenant SaaS capable of hosting an arbitrary number of white-label small-business sites. Key engineering choices that made this feasible within a single sprint:
- No new service or process. Hub-and-spoke is a function call, not a network hop. All multi-tenancy is keyed by the
Hostheader and asite_idUUID, reusing the existing auth, email, cache, and database infrastructure. - Shared types, zero drift.
mop_dev_sharedcompiles for both native and WASM, eliminating the risk of server and client rendering different virtual DOM trees. - IDOR defense at the extractor layer. Ownership is proven before handler code runs, making it structurally impossible to serve a resource to a non-owner.
- Lazy, conditional data loading. Products and forms are loaded only when the page layout actually declares them, keeping the
SiteConfigpayload small. - Pure, tested business logic. The pricing engine, availability logic, and money formatting are all pure functions with no I/O dependencies — fully testable in isolation without a running server.
- Clean seams for deferred work. AI-assisted OCR, Google OAuth, and automated catalog extraction are all behind explicit gates (a
GOOGLE_GBP_CLIENT_IDenv check, acatalog_jobsstatus field, adraftproduct status) that require no schema changes to activate when the time comes.