Quickback Docs

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:

  1. Adds custom_domain routes to wrangler.toml
  2. Generates hostname-based middleware in the Worker
  3. Auto-configures cross-subdomain cookies for shared authentication
  4. 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

DomainConfigServesAPI Routes
api.example.comproviders.runtime.config.routesAPI onlyAll
cms.example.comcms: { domain }CMS SPABlocked (404)
auth.example.comaccount: { domain }Account SPAPass-through
admin.example.comaccount: { adminDomain }Account SPAPass-through
www.example.comapps: { <name>: { domain } }User-authored SPAPass-through
app.example.comdomainEverythingAll
mcp.example.comagents.mcp.servers.<key>.publicUrlA named MCP server at the URL's pathAll

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",
  },
}
OptionRequiredDescription
domainYesPrimary custom hostname served at root (/)
aliasDomainsNoAdditional hostnames that serve the same SPA. Each gets its own custom_domain = true route. Common case: apex (example.com) alongside www.example.com.
assetsDirNoSubdirectory 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:

PathContent
/Redirects to /cms/
/cms/CMS SPA
/account/Account SPA
/api/v1/...API routes
/auth/...Auth routes
/admin/...Admin API routes
/storage/...File storage
/healthHealth check
/openapi.jsonOpenAPI 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

quickback/quickback.config.ts
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' and secure: true on 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 FlagRoutes Excluded When false
auth.organizations/dashboard (org list + invitations), organization CRUD, org switching ($slug)
auth.passkeyPasskey management and setup
auth.adminAdmin 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:

  1. The base config emits no routes block. Wrangler treats routes as an inheritable key — a top-level block would be inherited by any [env.*] that declares none, and deploy -e dev would rebind the production hostname to the dev Worker.
  2. 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>.domains supplies 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: { /* … */ } },
},
wrangler.toml (generated)
# 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 keyClaims
primarydomain, or this target's own all-in-one host when there is no top-level domain
apiapi.domain
authauth.domain
cmscms.domain
accountaccount.domain
adminaccount.adminDomain
webhookthe 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                 # → 200

The 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 Origin hostname matches — over https:// only (plain http:// 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.

wrangler.preview.toml
[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 preview

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

wrangler.preview.toml
[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

On this page