Adapted from the repo's docs/adr/0010-client-api-separate-hosts-v1-base-path.md — Part of Internals: the platform's design set, published as content on the platform it describes.
- Status: Accepted
- Date: 2026-07-06
- Deciders: platform-engineer (drafted); contract-owner (steward — numbers/ratifies); lead + user (decision)
- Tiers affected: contract, infra, backend, frontend
- Related: ADR-0003 (DDEV topology baseline),
ADR-0004 (application stack),
ADR-0006 (read-only public API),
contract
servers:block,../architecture.md(Environments)
Context
The topology started as a single-host split: the Next.js client served from the bare
host on :3000, and the Laravel API mounted under /api/v1 on that same bare host. This
couples the two tiers to one origin and one certificate, and bakes a path convention
(/api/v1) that mixes a routing concern (/api) with the contract's version segment
(v1). Two forces now require deciding the host layout, and it cannot be deferred because
the /shell browser terminal island (ADR-0006 read-only API, shipped) already issues
browser fetch calls that must resolve a stable, CORS-correct API origin, and production
bflo.sh needs a wiring story that is config-only:
- The contract's
servers:block is the single source of truth for where consumers reach the API; it must name one canonical base URL per environment. - CORS, the DDEV TLS certificate, and the frontend's API-base env var all have to agree on
that one origin — a split-brain here surfaces as browser fetch failures in
/shell. - Production must differ from local by environment values only, never by code or hardcoded hosts (ADR-0004 — no deploy target baked into the skeleton).
Decision
We will host the two tiers on separate hosts and drop /api from the path: the bare
host serves the Next.js client, and an api. subdomain serves the Laravel API under
/v1 (the contract version segment is the whole prefix). Concretely:
- Local (DDEV):
https://publishing-platform.ddev.site(client) andhttps://api.publishing-platform.ddev.site/v1(API). - Production:
https://bflo.sh(client) andhttps://api.bflo.sh/v1(API).
Both the host layout and the base path are env-driven on every tier (contract
servers: entries, DDEV additional_hostnames/routing, backend route prefix + CORS
allowed origin, frontend API-base env), so promoting local → production is a config-only
change with no hardcoded hosts.
Alternatives considered
- Keep the single-host
/api/v1split (client:3000, API on bare host) — no DNS or cert changes. Rejected: it forces same-origin coupling, conflates the/apirouting prefix with the contract'sv1version segment, and leaves production as a code-shaped change rather than a config swap. - Path-based split on one host (
/client,/apireverse-proxied to Laravel) — one origin, so no cross-origin CORS. Rejected: it requires an in-front proxy/rewrite layer to own the split, ties the API's public path to the client's routing table, and still bakes/apiinto the contract base URL; theapi.subdomain expresses the tier boundary more cleanly and matches the contract's per-server model. - Version in a header instead of the path — keeps one flat path. Rejected: the contract
already versions via the URL segment; a header scheme is harder to browse, cache, and lint
against
servers:.
Consequences
- Easier / now true: the client and API are cleanly separated by origin; the contract
servers:block names one canonicalapi.<host>/v1URL per environment; production is a config-only step (point the same four env values atbflo.sh/api.bflo.sh), no code edit. The/v1prefix is exactly the contract version, so the URL reads as the contract. - Harder / now required:
- CORS allowed origin becomes the bare client host (
publishing-platform.ddev.sitelocally,bflo.shin prod) — the API now answers cross-origin browser requests, so the CORS config must be kept in lockstep with the client host (backend-api owns the CORS config; platform owns the env value it reads). - The DDEV certificate must cover the
api.host as well as the bare host (added viaadditional_hostnames), or browser fetches from/shellfail TLS. - The
/shellbrowser island now fetches cross-origin to theapi.host — its API-base must be theapi.origin, not a relative/api/v1path.
- CORS allowed origin becomes the bare client host (
- Follow-up owners:
contract-owner— updateservers:to the two-host/v1URLs;platform-engineer— DDEVadditional_hostnamesforapi.<host>+ cert coverage + the env values;backend-api— route prefix/v1and CORS allowed-origin binding;frontend-engineer— API-base env pointing at theapi.origin (incl. the/shellisland).
Compliance
- The contract
servers:block lists exactly the two-host/v1URLs per environment, andddev contract-lintpasses against it. ddev exec curl -sf https://api.publishing-platform.ddev.site/v1/healthsucceeds from inside the topology (API reachable under/v1on theapi.host with a trusted cert).- A browser
fetchfrom the client host toapi.<host>/v1returns anAccess-Control-Allow-Originmatching the bare client host — the CORS//shellcross-origin path proven at runtime. - No tier hardcodes a host or the
/v1base; all of them read it from env / the contractservers:block (import/config scan across infra, backend, frontend).