Skip to content
bflo.sh

ADR-0010: Client and API on separate hosts; API base path `/v1`

ADR-0010 — Accepted 2026-07-06. Part of the platform's decision record.

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) and https://api.publishing-platform.ddev.site/v1 (API).
  • Production: https://bflo.sh (client) and https://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

  1. Keep the single-host /api/v1 split (client :3000, API on bare host) — no DNS or cert changes. Rejected: it forces same-origin coupling, conflates the /api routing prefix with the contract's v1 version segment, and leaves production as a code-shaped change rather than a config swap.
  2. Path-based split on one host (/ client, /api reverse-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 /api into the contract base URL; the api. subdomain expresses the tier boundary more cleanly and matches the contract's per-server model.
  3. 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 canonical api.<host>/v1 URL per environment; production is a config-only step (point the same four env values at bflo.sh / api.bflo.sh), no code edit. The /v1 prefix 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.site locally, bflo.sh in 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 via additional_hostnames), or browser fetches from /shell fail TLS.
    • The /shell browser island now fetches cross-origin to the api. host — its API-base must be the api. origin, not a relative /api/v1 path.
  • Follow-up owners: contract-owner — update servers: to the two-host /v1 URLs; platform-engineer — DDEV additional_hostnames for api.<host> + cert coverage + the env values; backend-api — route prefix /v1 and CORS allowed-origin binding; frontend-engineer — API-base env pointing at the api. origin (incl. the /shell island).

Compliance

  • The contract servers: block lists exactly the two-host /v1 URLs per environment, and ddev contract-lint passes against it.
  • ddev exec curl -sf https://api.publishing-platform.ddev.site/v1/health succeeds from inside the topology (API reachable under /v1 on the api. host with a trusted cert).
  • A browser fetch from the client host to api.<host>/v1 returns an Access-Control-Allow-Origin matching the bare client host — the CORS//shell cross-origin path proven at runtime.
  • No tier hardcodes a host or the /v1 base; all of them read it from env / the contract servers: block (import/config scan across infra, backend, frontend).