# Multi-Domain Architecture

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

| 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 |
| `quickback.example.com` | Auto-inferred | Everything | 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.

```typescript
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](/tooling/output#source-apps-quickbackapps) 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:

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

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

## Configuration

```typescript title="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:

```toml
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 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](/platform/database/neon#named-cloudflare-deployment-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.

```typescript
// 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: { /* … */ } },
},
```

```toml title="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` key | Claims |
|---------------|--------|
| `primary` | `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.

`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**:

| Target | Claims | Inferred unified host |
|--------|--------|-----------------------|
| `dev` | `api-dev.attend.vip` | `quickback-dev.attend.vip` |
| `prod` | `api.attend.vip` | `quickback-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:

```typescript
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.

```toml title="wrangler.preview.toml"
[vars]
EXTRA_APP_HOSTS = "app-preview.example.com,pr-42.example.com"
```

```bash
# 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`.

```toml title="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

- [Configuration](/configure) — CMS and Account config options
- [Output Structure](/tooling/output) — Generated file and directory layout
- [CMS](/ui/admin) — Schema-driven admin interface
- [Account UI](/ui/account) — Authentication and account management
