Rustango docs
← Cookbook

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 proper
  • routes.login_url, routes.logout_url, routes.change_password_url, routes.impersonation_handoff_url
  • routes.static_url/{*rest}, routes.brand_url/{*rest}
  • /__end-impersonation (hardcoded fallback inside handle_request)
  • Legacy /__admin* mounts for back-compat with RouteConfig::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(&registry).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 returns Page not found: … (404, not the admin's {"error":"table not found"})

Migration

App shapeBehavior 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/* linksUnchanged.
Apps that intentionally relied on the admin catching random URLsWill 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 /edit suffix.
  • Root pages use an empty slug (the resolver matches WHERE slug = ''), so the slug field's required attribute is conditional on having a parent — root creation submits an empty slug.
  • AdminError walks Error::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) (alongside router(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_map walk in tree order.