Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.
Date: 2026-05-25
Status: Accepted
Deciders: Daniel Meza
Related: ADR-006, ADR-013
Context and Problem Statement
The platform's two HTTP-serving components are:
- The web service — Next.js 14 App Router, also acting as the BFF (Backend-for-Frontend)
- The api service — Python FastAPI, intentionally internal-only (no public domain in v1)
On Railway, the api is reachable from inside the project at api.railway.internal:<port>. The browser cannot resolve api.railway.internal — it's a Railway-internal hostname that only resolves from inside the project's private network.
We learned this the hard way during the first staging deploy (May 2026). A client component (CatalogClient) imported the server-side fetch helper and used it on the browser:
'use client';
import { fetchCatalog } from '@/lib/catalog/fetch-catalog';
// later, in a useEffect:
const page = await fetchCatalog({ ... });When the component hydrated and React 18 strict mode's double-mount triggered the useEffect, fetchCatalog ran in the browser. The fetch resolution chain in the browser bundle:
process.env.INTERNAL_API_URL— undefined (server-only env var, not shipped to the client bundle)process.env.NEXT_PUBLIC_API_URL— also undefined- Fallback default:
http://localhost:3001
In local dev, localhost:3001 worked from the browser but failed CORS (FastAPI had no CORS middleware). In Railway staging, localhost:3001 didn't exist at all from the browser. Either way → silent fetch failure → user-visible error banner.
The fix took five reinforcing PRs to even DIAGNOSE before we realised the root cause was architectural: client components must not directly know about the api.
Decision Drivers
- Keep api internal-only. No public domain for the api in v1 — reduces attack surface, removes a CORS configuration burden, and aligns with the ADR-006 "internal services on Railway private network" baseline.
- Same DNS in dev and prod. A client-side path like
/api/catalog/productsworks identically againstlocalhost:3000(dev) andstaging.fincompare.cr(Railway) — no env-var indirection in the browser bundle. - No CORS. Same-origin requests skip the preflight + headers dance entirely. CORS is for when the api is a separate origin from the page; for our model the page IS the BFF, so the only origin involved is the web service.
- No env var leak to the browser.
INTERNAL_API_URLstays server-side.NEXT_PUBLIC_API_URLbecomes unnecessary. - Catch the regression at code-review time. A code-review rule + a checklist item is cheaper than re-running the five-PR debug.
- Pattern matches what the BFF was already claimed to be. §3 of Architecture reference describes the web service as a BFF that "aggregates API calls server-side." This ADR makes that pattern enforceable by closing the only escape hatch (client components importing server fetchers).
Considered Options
- Allow client components to call the api directly via a public api domain. Generate a Railway public domain for the api, add CORS middleware to FastAPI, set
NEXT_PUBLIC_API_URLon the web service. Works but exposes the api unnecessarily, adds CORS surface, and ships an api URL to the browser bundle. - Use Next.js route handlers as a same-origin proxy. Browser calls
/api/<resource>on the same origin as the page; the route handler runs server-side in the web container and proxies to<INTERNAL_API_URL>/<actual-path>. The browser never knows the api URL. (chosen) - Run the api on the same Railway service as the web. Co-locate FastAPI behind a path prefix on the web service. Couples deploys, breaks the language separation (Python + Node in one container), and makes scaling them independently impossible.
- Use server actions for every browser→api interaction. Next.js server actions ARE a same-origin RPC. Works for forms and mutations but is awkward for the catalog's filter-driven GET pattern. Considered for future write surfaces.
- Keep doing nothing and hope no one writes a client-component fetch. Status quo before this ADR. The fact that this happened once already disproves it.
Decision Outcome
Option 2: Next.js route handlers in apps/web/src/app/api/ act as same-origin proxies for every browser→api call.
The rule
Client components (any file with
'use client') MUST NOT call the api directly. They call a Next.js route handler at/api/<resource>on the same origin as the page. The route handler proxies to<INTERNAL_API_URL>/<api-path>server-side.
This applies to every browser-initiated fetch: filter refreshes, pagination, search, mutations, alert subscriptions — anything the browser triggers.
Structure
apps/web/src/
├── app/
│ ├── api/ ← Next.js route handlers (BFF proxy layer)
│ │ ├── catalog/
│ │ │ └── products/route.ts ← proxies /api/v1/products/tarjetas
│ │ ├── leads/route.ts ← proxies POST /api/v1/leads
│ │ └── (future endpoints)
│ └── tarjetas-de-credito/
│ └── catalogo/
│ └── page.tsx ← server component, may import api-client directly
└── components/
└── credit-cards/
└── catalog/
└── CatalogClient.tsx ← 'use client' — uses fetchCatalogClient (proxy)Two fetch helpers per resource
For each api endpoint that has both server-side and client-side callers, the codebase exposes two fetch helpers in apps/web/src/lib/:
fetchX— server-side, hits<INTERNAL_API_URL>/<path>directly. Used by page handlers and route handlers.fetchXClient— client-side, hits the relative/api/<resource>path on the same origin. Used by client components.
The server-side helper is the only place that knows about INTERNAL_API_URL. The client-side helper has no env-var surface — just a relative URL.
Route handler conventions
Each route handler:
- Reads request method, query string, body
- Forwards to
<INTERNAL_API_URL>/<api-path>with the appropriate method/body/query - Returns the upstream response body and status verbatim
- Logs request URL + non-2xx responses for diagnostic continuity with the server-side helpers
Route handlers are export const dynamic = 'force-dynamic' — they're thin pass-throughs, never cached at the Next.js layer (the upstream api owns its own cache policy).
What this does NOT cover
- Server components can continue to call api-client / fetch-catalog directly. They're already server-side.
- Server actions are an acceptable alternative for browser→server mutations. The proxy pattern is for GETs and any case where you want a typed fetch helper rather than a server action.
- Static asset fetches (images, fonts) don't go through this — they're served by Next.js or a CDN directly.
Positive Consequences
- api stays internal-only on Railway. No public domain, no CORS middleware needed on FastAPI.
- One pattern works in dev and prod. Client-side calls go to relative paths in both environments; the only thing that changes is what
INTERNAL_API_URLresolves to server-side. - No api URL in the browser bundle. Stops the
NEXT_PUBLIC_API_URLleakage and thelocalhost:3001fallback that broke production. - Easy diagnostic story. Browser DevTools Network tab shows requests to
/api/catalog/products— clear, scoped to one origin, easy to inspect. - Forward-compatible with future surfaces. Adding
/api/leads/route.tsfor FEAT-015's lead form follows the same shape — no architecture change required.
Negative Consequences / Trade-offs
- Extra hop per client-side request. Browser → web container → api container. Both hops are short (private network for the second), so the overhead is single-digit ms. Negligible at current scale.
- Two fetch helpers per resource. Slight duplication. Mitigated by extracting the query-string builder and response-envelope types into shared helpers (already done for fetch-catalog).
- More files in
apps/web/src/app/api/. Each new endpoint that needs browser access adds a small route handler. Maintenance overhead per handler is low. - Easy to forget. A new developer might import the server-side helper into a client component out of habit. Mitigated by:
- The naming convention (
fetchXClientis the client-safe one — the suffix is the hint) - The code review rule (
.claude/review-checklist.mditem) - The
/code-reviewerskill flags the import pattern
- The naming convention (
Pros and Cons of the Options
| Option | Browser→api hop | CORS surface | API public exposure | env-var leak | Adds patterns |
|---|---|---|---|---|---|
| 1. Direct call via public api domain | 1 (browser → api) | Yes — needs CORS middleware | Yes — public domain | Yes — NEXT_PUBLIC_API_URL | None |
| 2. Same-origin proxy (chosen) | 2 (browser → web → api) | None | None | None | Route handlers + Client fetch helpers |
| 3. Co-locate web + api | 1 (in-process) | None | None | None | Major — language and deploy coupling |
| 4. Server actions only | RPC | None | None | None | RSC server actions for every fetch |
| 5. Do nothing | 1 (broken) | Implicit (broken) | None | Yes (silently) | None |
Implementation in this codebase as of 2026-05-25
apps/web/src/app/api/catalog/products/route.ts— the first proxy, for the catalog endpointapps/web/src/lib/catalog/fetch-catalog.ts— exposes bothfetchCatalog(server) andfetchCatalogClient(browser via proxy)apps/web/src/components/credit-cards/catalog/CatalogClient.tsx— usesfetchCatalogClient
When the lead form (FEAT-015), saved comparisons (FEAT-013), or any other browser-initiated fetch surface lands, follow the same pattern:
- Add a route handler under
apps/web/src/app/api/<resource>/route.ts - Add a
fetchXClienthelper next to the existingfetchXserver-side one - Client components import the
Clientvariant
If a fetch only needs to happen at SSR time (server component), no route handler is needed — use the server-side helper directly.
References
- Architecture reference §3 — BFF caption (this ADR enforces what that caption claims)
- Architecture reference §9.5 — internal vs public api boundary (this ADR keeps that boundary intact)
- ADR-006 — Railway private network baseline
- ADR-013 — staging + production model
- Next.js docs: Route Handlers
- The full deploy-debug saga that produced this ADR: PRs #25–#32