Rustango docs
← Cookbook

Chapter 15 — v0.31 — tenant admin no longer catches every URL

The big architectural fix this cycle. Through v0.30 the tenancy server::Builder attached the tenant admin as Router::fallback_service(...) on the merged user router. Axum semantics: that overrides any .fallback() set inside the user's API router — so a CMS-style public site at / was impossible. Every unmatched URL got the admin's /{table} catch-all and returned {"error":"table not found"} instead of running the user's resolver.

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 0.1 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.

Companion fixes shipped in rustango-cms 0.1

The rustango-cms admin was unusable against the v0.30 serialization shape; v0.31's matching rustango-cms release fixes the template / handler bugs that surfaced building the end-to-end demo:

  • Template .Set referencesAuto<T> now serializes as the bare value (e.g. 1), not enum-tagged {"Set": 1}. The R-CMS admin templates were stuck on the old shape and 500'd with Variable t.id.Set not found in context. Replaced with {{ x.id }} everywhere.
  • Edit-form action URL — the form POSTed to /cms-admin/pages/{id} but the actual route is /cms-admin/pages/{id}/edit. Saving a page worked because the redirect-chain mostly worked out; the underlying mismatch was real.
  • slug field required attribute — root pages need an empty slug (the resolver matches WHERE slug = '') but the form blocked empty submit. The required is now conditional on parent so root creation works.
  • AdminError::IntoResponse walks Error::source() so Tera errors surface the actual cause line instead of the generic "Failed to render 'template.html'".
  • render(t, tera, page, url_prefix) — new url_prefix parameter, injected as {{ url_prefix }} into the Tera context so user templates can build breadcrumb / sibling links without hardcoding the host's URL layout.
  • router_at(prefix, tera) — kept alongside router(tera) for projects that want their CMS at a non-root prefix (e.g. /blog/ alongside other site content). Includes a permanent 308 redirect for {prefix}/{prefix} to handle axum's strict trailing-slash matching.
  • "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.