Chapter 15 — Tenant admin URL scoping
The tenant admin claims only an explicit set of routes; every other
URL falls through to your API router's .fallback(). That is what
lets a CMS-style public site live at / on the same tenant subdomain
as the admin.
(The admin used to be attached as Router::fallback_service(...) on
the merged router, which by axum's semantics overrides any
.fallback() in your API router — so every unmatched URL hit the
admin's /{table} catch-all and returned {"error":"table not found"}. Explicit routes fix that; the design rationale below still
applies.)
What changed
The framework now mounts the admin via explicit routes (see
build_admin_routes).
The fallback_service is gone. Routes claimed by the admin:
routes.admin_url+routes.admin_url/+routes.admin_url/{*rest}— admin properroutes.login_url,routes.logout_url,routes.change_password_url,routes.impersonation_handoff_urlroutes.static_url/{*rest},routes.brand_url/{*rest}/__end-impersonation(hardcoded fallback insidehandle_request)- Legacy
/__admin*mounts for back-compat withRouteConfig::legacy()apps
Everything else falls through to the user's .fallback() (or 404
if no fallback is set).
What this enables
The headline use case is a CMS-style public site on the same
tenant subdomain as the admin. The companion rustango-cms
crate ships a working setup:
let mut tera = Tera::new(&templates_glob)?;
rustango_cms::admin::register_templates(&mut tera)?;
let tera = std::sync::Arc::new(tera);
// CMS admin at /cms-admin/...; public pages at the site root.
let api = rustango_cms::admin::router(tera.clone())
.merge(rustango_cms::router(tera));
rustango::manage::Cli::new()
.tenancy()
.api(api)
.seed(|registry| async move {
rustango_cms::ensure_seeded(®istry).await?;
Ok(())
})
.run()
.await
After this:
/→ CMS root page/<slug>→ CMS resolver looks up the page/admin/...→ tenant admin/cms-admin/pages→ CMS-aware admin (path/depth/sort_order computed correctly, type whitelists enforced)/random-thing→ CMS resolver returnsPage not found: …(404, not the admin's{"error":"table not found"})
Migration
| App shape | Behavior change |
|---|---|
Custom routes + .fallback() (CMS-style) | Fallback now runs for unmatched URLs. If you worked around the bug with explicit /{*path} wildcards, you can simplify. |
| Just rustango admin, no custom routes | /random-url now returns 404 instead of admin's {"error":"table not found"} JSON. |
Custom routes, no .fallback() | Same as above — 404 for unclaimed URLs. |
Hardcoded /admin/* or /__admin/* links | Unchanged. |
| Apps that intentionally relied on the admin catching random URLs | Will break — set a custom .fallback() on your API router to keep the old behavior. |
Integrating a CMS-style site (rustango-cms)
A few things worth knowing when you wire rustango-cms (or any
template-driven site) alongside the tenant admin:
Auto<T>serializes as the bare value (1), not an enum-tagged{"Set": 1}— so templates reference{{ x.id }}, not{{ x.id.Set }}.- CMS edit forms POST to
/cms-admin/pages/{id}/edit— the edit route carries the/editsuffix. - Root pages use an empty slug (the resolver matches
WHERE slug = ''), so theslugfield'srequiredattribute is conditional on having aparent— root creation submits an empty slug. AdminErrorwalksError::source()so Tera errors surface the actual cause line instead of a generic "Failed to render 'template.html'".render(t, tera, page, url_prefix)injects{{ url_prefix }}into the Tera context, so templates build breadcrumb / sibling links without hardcoding the host's URL layout.router_at(prefix, tera)(alongsiderouter(tera)) mounts the CMS at a non-root prefix (e.g./blog/beside other content), with a 308 redirect for{prefix}/→{prefix}.- A "View live ↗" button on every published row of the CMS admin's
page list and on the edit-form header; URLs are pre-computed
server-side via a single-pass
build_live_url_mapwalk in tree order.