Multi-Domain Architecture
Serve CMS, Account UI, Admin, and user-authored SPAs on separate custom domains from a single Cloudflare Worker.
A single Quickback-compiled Cloudflare Worker can serve your API, CMS, Account UI, Admin panel, and your own product SPAs on separate custom domains. The compiler generates hostname-based routing so each domain serves the right content.
How It Works
When you configure custom domains for CMS, Account, Admin, or apps, the compiler:
- Adds
custom_domainroutes towrangler.toml - Generates hostname-based middleware in the Worker
- Auto-configures cross-subdomain cookies for shared authentication
- Requires an explicit
domain— the all-in-one host — when only cms/api/auth hostnames are declared and the bundled CMS or Account SPA is enabled
All configured domains point to the same Worker — there's only one deployment.
Domain Types
| Domain | Config | Serves | API Routes |
|---|---|---|---|
api.example.com | providers.runtime.config.routes | API only | All |
cms.example.com | cms: { domain } | CMS SPA | Blocked (404) |
auth.example.com | account: { domain } | Account SPA | Pass-through |
admin.example.com | account: { adminDomain } | Account SPA | Pass-through |
www.example.com | apps: { <name>: { domain } } | User-authored SPA | Pass-through |
app.example.com | domain | Everything | All |
mcp.example.com | agents.mcp.servers.<key>.publicUrl | A named MCP server at the URL's path | All |
CMS Domain
Serves the CMS SPA at root (/). API routes (/api/, /auth/, /admin/, /storage/, /health) return 404 — preventing direct API access on the CMS domain.
Account Domain
Serves the Account SPA at root (/). API and auth routes pass through to the Worker so authentication flows work directly on this domain.
Admin Domain
Identical behavior to the Account domain — serves the same Account SPA at root. The Account SPA's client-side router handles the /admin route, restricting access to users with the admin role.
App Domains
Mount your own product SPAs on dedicated hostnames via the apps field. Each entry binds one or more hostnames to a directory under src/apps/<name>/ and inherits the same API/auth pass-through behavior as the Account domain — so the SPA can hit /api/v1/... and /auth/... same-origin without CORS.
apps: {
www: {
domain: "www.example.com",
aliasDomains: ["example.com"], // apex serves the same SPA
},
marketing: {
domain: "marketing.example.com",
},
}| Option | Required | Description |
|---|---|---|
domain | Yes | Primary custom hostname served at root (/) |
aliasDomains | No | Additional hostnames that serve the same SPA. Each gets its own custom_domain = true route. Common case: apex (example.com) alongside www.example.com. |
assetsDir | No | Subdirectory under src/apps/ to serve from. Defaults to the record key. Override only when the on-disk directory needs to differ from the URL-facing name. |
Drop the prebuilt SPA output at quickback/apps/<name>/ — the source-apps passthrough copies it byte-for-byte into src/apps/<name>/ on every compile. The compiler doesn't build the SPA; it just emits routing for whatever's already there. See Output Structure for the passthrough rules.
The names cms, account, and public are reserved (they collide with compiler-emitted directories under src/apps/). Two app entries can't claim the same hostname, and an app's hostname can't collide with cms.domain / account.domain / account.adminDomain.
Unified Domain
Set domain to the host where everything is available on a single origin:
| Path | Content |
|---|---|
/ | Redirects to /cms/ |
/cms/ | CMS SPA |
/account/ | Account SPA |
/api/v1/... | API routes |
/auth/... | Auth routes |
/admin/... | Admin API routes |
/storage/... | File storage |
/health | Health check |
/openapi.json | OpenAPI spec |
domain is required when the project's only custom hostnames are cms.domain, api.domain or auth.domain and the CMS or Account SPA is enabled: each of those hosts blocks part of the surface the SPAs need, so without it they would call an origin that 404s. The compile fails with config.domain: … Set domain to the all-in-one hostname. A providers.runtime.config.routes entry, an account.domain/adminDomain, or an apps.<name>.domain already serves everything, so projects with one of those need no domain.
No longer inferred
Earlier compilers inferred this host as quickback.{baseDomain} from the first declared hostname, which emitted a custom_domain route in a zone the deploying account might not own and failed wrangler deploy after the upload. Only declared hostnames are emitted now.
A host another Worker owns
domain also emits a custom_domain route for itself. Whichever Worker deploys last takes a custom domain, so if a separate front-door Worker owns that host (a static app that service-binds to this API, say), a routine deploy of the API moves the host to the API. Set domainRoute: false to keep domain as the unified origin everywhere it is used (trusted origins, SPA baked URLs, CSP, cookie inference, named-environment aliases) while binding nothing:
export default defineConfig({
domain: "intrio.app", // the front door's host
domainRoute: false, // the API never claims it
providers: {
runtime: { name: "cloudflare", config: { routes: [{ pattern: "api.intrio.app", custom_domain: true }] } },
},
});With named environments, a domainRoute: false host is not a generated source, so no target has to claim it under domains.
Named MCP Server Hosts
A named MCP server with a publicUrl (e.g. https://mcp.example.com/guest) gets that hostname bound as a custom_domain route on the same Worker — emitted automatically, not declared in providers.runtime.config.routes. A host the project already routes (the API host, a routes entry) is not emitted twice. On that host, the exact publicUrl path serves the MCP server and /.well-known/oauth-protected-resource<path> serves its metadata; every other path behaves as on any other host. No CORS or trusted-origin entry is needed: MCP clients call it server-to-server with a bearer token, and the OAuth screens stay on BETTER_AUTH_URL.
The host cannot also be a CMS, Account, admin, app, auth or webhook host — their hostname middleware would serve or block the MCP path, so the compile fails instead.
Configuration
export default {
name: "my-app",
template: "hono",
cms: { domain: "cms.example.com", access: "sysadmin" },
account: {
domain: "auth.example.com",
adminDomain: "admin.example.com",
name: "My App",
auth: { password: true, admin: true },
},
apps: {
www: {
domain: "www.example.com",
aliasDomains: ["example.com"],
},
},
providers: {
runtime: { name: "cloudflare", config: {
routes: [{ pattern: "api.example.com", custom_domain: true }],
}},
// ...database, auth
},
};trustedOrigins is optional here — every cms.domain, account.domain, account.adminDomain, and apps[*].domain (plus aliases) is automatically added. Declare trustedOrigins only when you need to allow an origin the compiler can't infer (e.g. a third-party domain).
This generates the following wrangler.toml routes:
routes = [
{ pattern = "api.example.com", custom_domain = true },
{ pattern = "cms.example.com", custom_domain = true },
{ pattern = "auth.example.com", custom_domain = true },
{ pattern = "admin.example.com", custom_domain = true },
{ pattern = "www.example.com", custom_domain = true },
{ pattern = "example.com", custom_domain = true },
{ pattern = "app.example.com", custom_domain = true }
]Cross-Subdomain Authentication
When two or more custom domains share a parent domain (e.g., cms.example.com + auth.example.com + www.example.com), the compiler automatically configures Better Auth for cross-subdomain cookie sharing. App hostnames (primary + aliases) are folded into the shared-parent detection alongside the CMS, Account, Admin, and top-level domain slots:
- Sets
crossSubDomainCookies: { enabled: true, domain: '.example.com' } - Sets
sameSite: 'none'andsecure: trueon auth cookies
This means a user who logs in on auth.example.com is automatically authenticated on cms.example.com, admin.example.com, and www.example.com — no additional setup needed. Apex aliases (e.g. example.com listed under aliasDomains) are recognized as already being the parent — they don't break shared-parent equality.
Automatic Detection
Cross-subdomain cookies are configured automatically. You only need to set them manually if your domains don't share a common parent (e.g., auth.myapp.com + cms.different.com).
Compile-Time Feature Gating
When the compiler builds the Account SPA, it excludes route files for disabled features before the Vite build. This means disabled features never appear in the JavaScript bundle.
| Feature Flag | Routes Excluded When false |
|---|---|
auth.organizations | /dashboard (org list + invitations), organization CRUD, org switching ($slug) |
auth.passkey | Passkey management and setup |
auth.admin | Admin panel routes |
This keeps bundle sizes minimal and prevents dead code in production. Users can't access disabled features even if they navigate to the URL directly — the routes don't exist in the build.
Hostname Routing Details
The compiler generates middleware that checks new URL(c.req.url).hostname on every request:
CMS domain — Blocks API routes with 404. All other paths are mapped to the /cms/ asset prefix and served with SPA fallback to /cms/index.html.
Account/Admin domain — API and auth routes (/api/, /auth/, /admin/, /storage/, /health) pass through to Hono handlers. All other paths are mapped to the /account/ asset prefix and served with SPA fallback to /account/index.html.
App domain — Identical to Account: API/auth/admin/storage/health pass through, other paths map to /<assetsDir>/ with SPA fallback. The multi-domain catchall (emitted when both CMS and Account are configured) explicitly skips app hostnames, so an API miss on an app host lands in app.notFound (404 JSON) instead of stray-serving a sibling SPA's HTML.
Unified domain — No hostname filtering. CMS is served at /cms/, Account at /account/. Root (/) redirects to /cms/. API routes work at their standard paths.
Without Custom Domains
If you enable cms and account without custom domains, everything is served on a single domain:
- CMS at
/cms/(root/redirects to/cms/) - Account UI at
/account/ - API at
/api/v1/ - Auth at
/auth/
This is the simplest setup — no DNS configuration needed, no cross-subdomain cookies. Auth cookies work naturally because everything is same-origin.
Named Environments
A project with named environments deploys the same bundle to several Workers (wrangler deploy -e dev, -e prod), and each target needs its own hostnames. Two rules make that safe:
- The base config emits no
routesblock. Wrangler treatsroutesas an inheritable key — a top-level block would be inherited by any[env.*]that declares none, anddeploy -e devwould rebind the production hostname to the dev Worker. - Every environment claims every hostname the top level generates. The top-level domain config still defines the shape — which roles exist and what the Worker bakes in for each — and
environments.<name>.domainssupplies the actual hostnames.
// quickback.config.ts — the worker block of each environment is omitted here;
// see Named deployment environments for the full target contract.
api: { domain: "api.attend.vip" }, // the SHAPE: this project has an API hostname
environments: {
dev: { domains: { api: "api-dev.attend.vip" }, worker: { /* … */ } },
prod: { domains: { api: "api.attend.vip" }, worker: { /* … */ } },
},# no top-level routes — each target owns its own
[env.dev]
routes = [
{ pattern = "api-dev.attend.vip", custom_domain = true },
]
[env.prod]
routes = [
{ pattern = "api.attend.vip", custom_domain = true },
]Each domains key maps 1:1 to a top-level source:
domains key | Claims |
|---|---|
primary | domain, or this target's own all-in-one host when there is no top-level domain |
api | api.domain |
auth | auth.domain |
cms | cms.domain |
account | account.domain |
admin | account.adminDomain |
webhook | the derived webhook.{baseDomain} route |
apps.<name>[i] | apps.<name>.domain (index 0) and each aliasDomains entry (1+) |
routes[i] | providers.runtime.config.routes[i] |
When the project generates exactly one hostname, domain: "api-dev.attend.vip" is accepted as shorthand for the single role.
Named MCP server hosts are not domains keys: agents.mcp.servers.<key>.publicUrl is itself keyed by environment ({ production: "https://mcp.attend.vip/guest", dev: "https://mcp-dev.attend.vip/guest" }), and each host is appended to that target's routes. The compiler also stamps QUICKBACK_ENVIRONMENT = "<name>" into each target's vars so the shared bundle knows which public URL is its own. A host claimed by another target fails the compile, like any other duplicate hostname.
domains: 'none' marks a workers.dev-only target. It emits routes = [] rather than omitting the block, so inheritance cannot hand that target a sibling's custom domain.
The compile fails, naming the config path to fix, when an environment leaves a generated hostname unclaimed, claims a key the top level does not generate, or claims a hostname another target already owns.
A target that extends
another never inherits its domain / domains. Hostnames are always claimed
per target, so two targets can never end up owning the same custom domain.
The unified hostname per target
Each target names its own all-in-one host with domains.primary — the host where every surface coexists and / redirects to /cms/:
dev: { domains: { api: "api-dev.attend.vip", primary: "app-dev.attend.vip" } },
prod: { domains: { api: "api.attend.vip", primary: "app.attend.vip" } },One custom domain cannot belong to two Workers, so a project with named environments has no shared top-level domain; domains.primary is how each target claims its own. Keep it one label deep (app-dev.attend.vip, not app.dev.attend.vip): Cloudflare's Universal SSL covers exactly one level of subdomain.
A target whose claimed hostnames are all cms/api/auth roles fails the compile without domains.primary when the CMS or Account SPA is enabled (dev claims custom hostnames but no domains.primary); an account, admin, app or routes[i] claim already serves everything. A domains: 'none' target needs none. When the project declares a top-level domain, that becomes a real primary source each target claims by hand instead.
Each target's domains.primary host joins trustedOrigins and the CSP origin list, but does not become the target's BETTER_AUTH_URL — that stays on the auth or API hostname, matching what a single-target project resolves.
One bundle, several hostnames
Every environment runs the same compiled Worker, so hostname-sensitive behavior is baked once, at compile time, against every environment's hostnames at once. domains is what makes that work: an environment's cms hostname gets the CMS domain's API-route block, its api hostname gets the /v1/* rewriter, its app hostnames serve the app SPA, and all of them join trustedOrigins, the CSP origin list, and cross-subdomain cookie inference.
Each target's BETTER_AUTH_URL also defaults to the hostname it claims (unified → auth → API → UI), so a dev deploy authenticates against its own origin instead of production's. Setting worker.vars.BETTER_AUTH_URL explicitly still wins.
Host allowlist
A Worker is also reachable at <name>.<account>.workers.dev (each environment must set workersDev: true), which would put the whole API on an origin the config never named — outside your custom-domain TLS policy, WAF rules, and CSP origins. So the generated worker answers only on hostnames the config claims: every generated route (domain, api.domain, auth.domain, cms.domain, account.domain, account.adminDomain, apps.* domains and aliases, explicit providers.runtime.config.routes) plus every hostname a named environment claims under environments.<name>.domains. Any other host gets 404 NOT_FOUND before routing:
curl https://my-api.my-account.workers.dev/health # → 404 {"code":"NOT_FOUND","layer":"route","host":"…"}
curl https://api.example.com/health # → 200The gate is extended at runtime, without a recompile, by loopback hosts (wrangler dev), the BETTER_AUTH_URL host, and every entry in EXTRA_APP_HOSTS (below). A wildcard route host (*.example.com) is a suffix match. A project with no custom hostnames at all is workers.dev-only by design and has no gate. Durable Objects, queues and cron handlers are not affected — only the fetch entry.
Preview Deploys
The same compiled bundle can be deployed to additional hostnames — preview environments, staging, per-PR URLs — without recompiling. Set the EXTRA_APP_HOSTS env var on the deploy target (a comma-separated list of hostnames) and the worker treats each one like an entry in apps[*].domain:
- The host allowlist admits the hostname.
- Apps hostname middleware serves the SPA at root.
- CORS trusts requests whose
Originhostname matches — overhttps://only (plainhttp://is accepted for loopback hosts, for local development). - Better Auth's own origin check (independent of CORS) trusts each hostname as
an
https://origin — an entry with an explicit scheme is used verbatim.
Empty or unset means no extension and zero behavior change for production.
[vars]
EXTRA_APP_HOSTS = "app-preview.example.com,pr-42.example.com"# Or set as a secret per deploy target
wrangler secret put EXTRA_APP_HOSTS --env previewMirrors the existing BETTER_AUTH_URL auto-trust behavior, where the deployed worker auto-trusts its own configured base origin without needing an explicit trustedOrigins entry. With EXTRA_APP_HOSTS, one env var extends all three allowlists — apps, CORS, and Better Auth's trustedOrigins.
Multi-app deploys
When more than one app is hostname-mounted, extras attach to the first registered apps middleware (Hono runs middleware in registration order and terminates on first match). If you need extras to route to a specific app in a multi-app deploy, set apps[*].domain directly and recompile rather than using this env var.
Per-deploy app URL
account.appUrl is baked at compile time into the Account SPA's runtime config and used as the "Go To App" link base in transactional emails — so a preview or dev worker would send emails and render links pointing at production. Set APP_URL on the deploy target to override both at serve time: the worker merges it into the runtime config injected into every served shell, and email templates prefer it over ACCOUNT_URL.
[vars]
APP_URL = "https://app-preview.example.com"Unset means zero behavior change. (Setting it changes the injected runtime-config bytes, so a strict-CSP deploy relying on the precomputed script hash should keep it unset on the production target — the same tradeoff as named-dev host injection.)
See Also
- Configuration — CMS and Account config options
- Output Structure — Generated file and directory layout
- CMS — Schema-driven admin interface
- Account UI — Authentication and account management
Pinned Organization Mode
Pin every authenticated request to one organization id while keeping org-backed auth, roles, and CMS support.
AI Agent Readiness
Every Quickback Worker ships agent-discovery files and an auto-generated MCP server. Let Claude, ChatGPT, Cursor, and Perplexity find and call your API out of the box.