Every SaaS reaches the same fork: acme.yourapp.com and globex.yourapp.com should be the same product serving different data. The common hack is to read the Host header in middleware and thread a tenant id through every call by hand. In Stone.js the subdomain is part of the route, so the tenant arrives already captured.

The tenant is in the host, so match on the host

A route can be scoped to a host, and that host can carry parameters just like a path can. A subdomain parameter turns multi-tenancy into routing: the router captures the tenant from the host and puts it on the event, next to your path and query values. No header parsing, no per-handler plumbing, no tenant argument passed down a call chain.

acme.example.coma tenant requestHost match{tenant}.example.comHandlerevent.get('tenant')Tenant's datascoped by tenant
The subdomain is a route parameter. The router captures the tenant during matching; the handler just reads it, no header-parsing plumbing.

Capture the subdomain

Declare the parameter on the handler's domain, then read it on the event. It behaves like any other captured value, so the tenant is available everywhere the event is, without you doing anything to carry it.

app/TenantController.tsts
import { EventHandler, Get } from '@stone-js/router'

@EventHandler('/', { domain: '{tenant}.example.com' })   // capture a subdomain
export class TenantController {
  @Get('/dashboard')
  dashboard (event) {
    const tenant = event.get('tenant')      // from the host, not the path
    return this.tenants.dashboard(tenant)
  }
}

Constrain the host, share it across a group

Host parameters take constraints too, through rules, so you can keep a reserved subdomain like admin from ever matching the tenant pattern. And because a whole group can share a domain, an admin subdomain and a tenant subdomain become two groups over the same routes, cleanly separated at the host.

app/routes.tsts
@Get('/', { domain: 'admin.example.com' })   // the reserved host, matched first

@EventHandler('/', {
  domain: '{tenant}.example.com',
  rules: { tenant: /[a-z0-9-]+/ }             // constrain the subdomain
})
export class TenantController { /* ... */ }

Because the reserved admin.example.com is a literal host and the tenant pattern is parameterised, the router prefers the specific one, so admin never resolves to a tenant named "admin".

Why it matters

  • The tenant is captured once. It is a route parameter on the event, not a header you re-parse in every handler.
  • Isolation is structural. A group scoped to a host keeps admin and tenant surfaces apart at the routing layer, not by convention.
  • It is just routing. No tenancy framework to adopt, and the same handlers still run on every runtime an adapter supports.

Host and subdomain matching, including how the router ranks a literal host over a parameterised one, is in Matching and precedence. Sharing a domain and other options across many routes is covered in Groups and prefixes.