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. Auto-infers a unified quickback.{baseDomain} fallback domain

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
quickback.example.comAuto-inferredEverythingAll

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

The compiler auto-infers a quickback.{baseDomain} domain from your configured custom domains. For example, if you have cms.example.com, it creates quickback.example.com.

On this domain, 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

The unified domain is useful for development and debugging. You can override it with an explicit domain field in your config.

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 = "quickback.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
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.

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.

The unified hostname per target

The auto-inferred quickback.{baseDomain} host — where every surface coexists and / redirects to /cms/ — is inferred for named-environment projects too, once per target:

TargetClaimsInferred unified host
devapi-dev.attend.vipquickback-dev.attend.vip
prodapi.attend.vipquickback-prod.attend.vip

The single-target name can't carry over verbatim: every environment would infer the same quickback.attend.vip from a shared base domain, and one custom domain cannot belong to two Workers. The environment key is the only thing that makes the name unique by construction.

It's a hyphen rather than a new label (quickback-dev.attend.vip, not quickback.dev.attend.vip) because Cloudflare's Universal SSL covers exactly one level of subdomain — the extra label would deploy without a certificate.

Set domains.primary to override the name on any target. That's how one environment keeps the bare hostname a single-target project would have inferred:

prod: { domains: { api: "api.attend.vip", primary: "quickback.attend.vip" } },

Inference is skipped when the project declares domain explicitly (that becomes a real primary source each target claims by hand), when the top level configures no domains at all, and for a domains: 'none' target.

These are real custom domains

Every inferred hostname is emitted as a custom_domain route, so Wrangler provisions the DNS record on deploy — same as the single-target quickback.{baseDomain} has always done. The zone has to be on the deploying Cloudflare account.

Each target's unified 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.

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:

  • Apps hostname middleware serves the SPA at root.
  • CORS trusts requests whose Origin hostname matches.
  • 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. (Earlier v0.64 output stopped at the CORS layer: preflight passed, then Better Auth's own check rejected the origin. On older output, recompile — or set BETTER_AUTH_URL on the deploy target, since Better Auth always trusts its own base URL.)

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