Cookbook / Multi-tenant apps
Host-based routing and custom domains
Serve storefronts from subdomains and customers' own domains, and keep the admin app on its own host.
A multi-tenant product often answers on many hosts: acme.stores.example.com for each customer, admin.example.com for staff, and customers' own domains. Server::host gives a host its own router, and ResolveHost looks up domains that are known only at runtime.
use axeon::host::ResolveHost;
use axeon::{Router, Server, ServerError};
/// What a custom domain resolves to, from your database.
#[derive(Clone)]
struct Storefront {
name: String,
}
fn main() {
// A wildcard host: every subdomain of stores.example.com.
let mut stores = Router::new();
stores.get("/", |req| async move { format!("welcome to the {} store", req.subdomain().unwrap_or("?")) });
// An exact host for staff.
let mut admin = Router::new();
admin.get("/", |_req| async { "admin console" });
let mut app = Server::new();
app.host("*.stores.example.com", stores);
app.host("admin.example.com", admin);
// Everything else is the main site.
app.get("/", |_req| async { "the main site" });
app.listen("127.0.0.1:3000").expect("server failed to start");
// Customers' own domains are not known at startup. Resolve them per request with
// `ResolveHost` (see `custom_domains` below), as middleware on the server.
}
#[allow(dead_code)]
fn custom_domains() -> Server {
let mut app = Server::new();
app.middleware(
ResolveHost::new(|host: String| async move {
// SELECT name FROM storefronts WHERE domain = $1 AND verified
Ok::<_, ServerError>((host == "shop.acme.com").then(|| Storefront { name: "Acme".into() }))
})
.skip(["example.com", "www.example.com"]), // your own domains pass through
);
app.get("/", |req| async move { req.extension::<Storefront>().map(|s| format!("{} storefront", s.name)) });
app
}
curl http://127.0.0.1:3000/ -H 'host: acme.stores.example.com' # welcome to the acme store
curl http://127.0.0.1:3000/ -H 'host: admin.example.com' # admin console
curl http://127.0.0.1:3000/ -H 'host: anything-else.test' # the main site
Notes#
- A wildcard matches exactly one label:
*.stores.example.commatchesacme.stores.example.com, but notstores.example.comora.b.stores.example.com. The label may hold only letters, digits,-and_. - A host's router owns it completely: a path it does not route is
404, not a fall back to the main site's routes or static files. Global middleware still runs for every host. Patterns are tried in the order you add them. - Behind a proxy, make sure it passes the original
Hostthrough. ResolveHostcaches lookups, including "unknown", for a minute, so a domain you remove keeps working that long. Unknown hosts answer404.- Use
req.host()for the normalised host name (lowercase, without port).