Architecture decision ADR-014

Browser Data Fetching: Same-Origin Proxy via BFF Route Handlers

Accepted · 2026-05-25

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:

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:

  1. process.env.INTERNAL_API_URL — undefined (server-only env var, not shipped to the client bundle)
  2. process.env.NEXT_PUBLIC_API_URL — also undefined
  3. 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

Considered Options

  1. 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_URL on the web service. Works but exposes the api unnecessarily, adds CORS surface, and ships an api URL to the browser bundle.
  2. 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)
  3. 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.
  4. 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.
  5. 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/:

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:

  1. Reads request method, query string, body
  2. Forwards to <INTERNAL_API_URL>/<api-path> with the appropriate method/body/query
  3. Returns the upstream response body and status verbatim
  4. 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


Positive Consequences

Negative Consequences / Trade-offs


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

When the lead form (FEAT-015), saved comparisons (FEAT-013), or any other browser-initiated fetch surface lands, follow the same pattern:

  1. Add a route handler under apps/web/src/app/api/<resource>/route.ts
  2. Add a fetchXClient helper next to the existing fetchX server-side one
  3. Client components import the Client variant

If a fetch only needs to happen at SSR time (server component), no route handler is needed — use the server-side helper directly.


References