ADR-003: BFF Architecture with Catch-All API Proxy
Date: 2026-02-19
Status: Accepted
Context
The Chompardo platform has three backend components:
- Identity service (
main-identity.chompardo.dev) — React Router 7 in framework mode running Better Auth with an OAuth provider plugin that issues short-lived JWTs - App frontend (
main.chompardo.dev) — React Router 7 in framework mode (SSR), serving the main application UI with server-side loaders and actions - Go API (
main-api.chompardo.dev) — Stateless Go modular monolith serving domain data
We need to decide how the browser, the app frontend, and the Go API communicate:
- Session management — How the browser authenticates with the app frontend
- API access — How the app frontend accesses the Go API on behalf of the user
- Routing — How API requests from the frontend are mapped to the Go API
Key forces:
- The Go API should be stateless — no sessions, no cookies, no token refresh. This keeps it focused on domain logic and makes it horizontally scalable without shared session state
- The browser should never hold or transmit JWTs — tokens stored in
localStorageor sent viaAuthorizationheaders are vulnerable to XSS. HttpOnly session cookies are the secure default - React Router 7 in framework mode already has a server-side runtime (loaders, actions) — it can act as a Backend for Frontend (BFF) without adding infrastructure
- The team wants to avoid manually maintaining a route mapping for every Go API endpoint in the BFF layer
- The BFF should be free to aggregate data from multiple backend services, add caching, or reshape responses for the UI without changing the Go API
Decision
HttpOnly session cookies between browser and BFF
The app frontend (main.chompardo.dev) authenticates users via the identity service using the OAuth/OIDC flow. After authentication, the BFF holds the user's session as an HttpOnly, Secure, SameSite cookie. The browser never sees or handles JWTs.
BFF proxies to the Go API with short-lived JWTs
When a React Router loader or action needs data from the Go API, the BFF exchanges the user's session for a short-lived JWT (obtained from the identity service's token endpoint) and forwards the request to the Go API with the JWT in the Authorization header. The Go API validates the JWT and serves the request — no session lookup, no cookie parsing, no token refresh logic.
Catch-all proxy route for transparent API forwarding
A catch-all route in the app frontend (e.g., app/routes/api.$.ts) proxies all /api/* requests to the Go API. The route handler injects the JWT and forwards the request and response transparently. This means:
- New Go API endpoints are automatically available through the BFF without adding routes
- The BFF can selectively override specific routes (e.g.,
app/routes/api.dashboard.ts) to aggregate data from multiple services or add caching, while the catch-all handles everything else - The frontend calls
/api/recipeson its own origin — no CORS, no cross-origin cookies
Consequences
Positive
- Secure by default — JWTs never reach the browser; HttpOnly cookies are immune to XSS token theft
- Stateless Go API — The API only validates JWTs; no session store, no cookie middleware, no token refresh endpoint. Horizontal scaling is trivial
- Zero-maintenance routing — The catch-all proxy means adding a new Go API endpoint requires no changes to the BFF
- Flexible aggregation — The BFF can override specific routes to combine data from multiple backends, add server-side caching, or reshape responses — all invisible to the browser
- Same-origin requests — The browser only talks to
main.chompardo.dev; API calls are same-origin, eliminating CORS between the frontend and API - No additional infrastructure — React Router's server runtime is the BFF. No API gateway, no sidecar proxy, no extra service to deploy
Negative
- Extra network hop — Every API request goes Browser → BFF → Go API instead of directly to the API. Both services will be co-located, so the hop is sub-millisecond in production
- BFF is a bottleneck — If the Node.js BFF goes down, all API access is lost. This is acceptable because the UI it serves would also be down
- Token management complexity — The BFF must handle token acquisition, caching, and refresh. This is contained to one place (the proxy middleware) but is non-trivial logic
- Debugging indirection — Request traces span two services (BFF + Go API); correlating logs requires a request ID passed through the proxy
Alternatives Considered
1. Browser calls Go API directly (SPA pattern)
The frontend is a client-side SPA that calls the Go API directly at main-api.chompardo.dev. The browser sends JWTs via Authorization headers or the Go API manages sessions with cookies.
Rejected because:
- Storing JWTs in the browser (localStorage or sessionStorage) exposes them to XSS — any injected script can exfiltrate the token
- The Go API would need session management or token refresh logic, making it stateful and more complex
- Loses the ability to aggregate or reshape API responses on the server side without adding a separate gateway
2. Dedicated API gateway (Kong, Traefik, or similar)
A standalone API gateway sits between the browser and the Go API, handling authentication, rate limiting, and routing.
Rejected because:
- Adds a new piece of infrastructure to deploy, configure, and maintain — overkill for a small team with one API backend
- Duplicates functionality that React Router's server runtime already provides (request handling, middleware, response transformation)
- Gateway configuration is typically declarative YAML/HCL that is harder to customize than writing TypeScript in a React Router route
3. Go API handles sessions directly
The Go API manages user sessions via cookies, eliminating the need for a BFF proxy layer.
Rejected because:
- Makes the Go API stateful — it needs a session store (Redis, database, or in-memory) and cookie middleware, complicating horizontal scaling
- Couples the API to browser-specific concerns (cookie attributes, CSRF protection, session expiry) that don't belong in a domain-focused service
- Loses the aggregation and caching flexibility that the BFF provides