fix(frontend): proxy /api and /sitemap.xml at runtime, not via baked rewrites #14

Merged
tudor merged 1 commits from fix/runtime-api-proxy into main 2026-07-06 10:11:39 +00:00
Owner

Problem

The /api/* proxy lived in next.config.js rewrites(), whose destination is evaluated at build time and serialized into routes-manifest.json. CI builds the frontend image once with FASTAPI_URL=http://backend:80/api, so http://backend is baked in and the runtime FASTAPI_URL env is ignored for the proxy.

Because a single image is promoted staging→prod, that baked host forces every environment to name the backend service identically. Prod names it backend (works); staging names it backend_stg, so the browser's client-side /api/* calls proxied to http://backend and failed:

Failed to proxy http://backend/api/compare... getaddrinfo ENOTFOUND backend

SSR was unaffected — lib/api.ts reads FASTAPI_URL at runtime for server-side fetches — which is why pages loaded but client-side refetches (e.g. the compare chart) broke. This is the true root cause behind the earlier staging compare failures.

Fix

Replace the build-time rewrites with App Router route handlers that read FASTAPI_URL per request:

  • app/api/[...path]/route.ts — transparent proxy for all methods; forwards query + body, streams the upstream response, strips hop-by-hop/length headers (so undici's decoded body isn't mis-tagged), and returns a clean 502 on upstream failure (DNS/connection) instead of an opaque crash.
  • app/sitemap.xml/route.ts — proxies the backend's generated sitemap (robots.ts points crawlers here).
  • next.config.jsrewrites() removed.

The same promoted image now adapts to whatever the backend is called in each environment, so staging (backend_stg) and prod (backend) both work with one build. No env/compose changes required.

Verification

  • npm run typecheck, npm test (33), and a full npm run build all pass.
  • Build output lists ƒ /api/[...path] and ƒ /sitemap.xml as dynamic route handlers, and routes-manifest.json rewrites are now empty (nothing baked).
  • Post-merge, the staging compare page (client-side /api/compare) should reach backend_stg and the compare-chart E2E should pass on the gate.

🤖 Generated with Claude Code

## Problem The `/api/*` proxy lived in `next.config.js` `rewrites()`, whose destination is evaluated at **build time** and serialized into `routes-manifest.json`. CI builds the frontend image once with `FASTAPI_URL=http://backend:80/api`, so `http://backend` is baked in and the runtime `FASTAPI_URL` env is ignored for the proxy. Because a single image is promoted staging→prod, that baked host forces every environment to name the backend service identically. Prod names it `backend` (works); staging names it `backend_stg`, so the browser's client-side `/api/*` calls proxied to `http://backend` and failed: ``` Failed to proxy http://backend/api/compare... getaddrinfo ENOTFOUND backend ``` SSR was unaffected — `lib/api.ts` reads `FASTAPI_URL` at runtime for server-side fetches — which is why pages loaded but client-side refetches (e.g. the compare chart) broke. This is the true root cause behind the earlier staging compare failures. ## Fix Replace the build-time rewrites with App Router route handlers that read `FASTAPI_URL` **per request**: - `app/api/[...path]/route.ts` — transparent proxy for all methods; forwards query + body, streams the upstream response, strips hop-by-hop/length headers (so undici's decoded body isn't mis-tagged), and returns a clean `502` on upstream failure (DNS/connection) instead of an opaque crash. - `app/sitemap.xml/route.ts` — proxies the backend's generated sitemap (`robots.ts` points crawlers here). - `next.config.js` — `rewrites()` removed. The same promoted image now adapts to whatever the backend is called in each environment, so staging (`backend_stg`) and prod (`backend`) both work with one build. No env/compose changes required. ## Verification - `npm run typecheck`, `npm test` (33), and a full `npm run build` all pass. - Build output lists `ƒ /api/[...path]` and `ƒ /sitemap.xml` as dynamic route handlers, and `routes-manifest.json` rewrites are now empty (nothing baked). - Post-merge, the staging compare page (client-side `/api/compare`) should reach `backend_stg` and the compare-chart E2E should pass on the gate. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
tudor added 1 commit 2026-07-06 09:01:12 +00:00
fix(frontend): proxy /api and /sitemap.xml at runtime, not via baked rewrites
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m13s
95a5783da1
next.config.js rewrites() bakes its destination into the build
(routes-manifest.json), capturing FASTAPI_URL at build time. Because one
frontend image is promoted staging->prod, the baked backend host forced
every environment to name the backend service identically; staging names
it 'backend_stg', so the browser's /api/* calls proxied to the baked
'http://backend' and failed with getaddrinfo ENOTFOUND backend. (SSR was
unaffected because lib/api.ts reads FASTAPI_URL at runtime.)

Replace the rewrites with route handlers that read FASTAPI_URL per
request:
- app/api/[...path]/route.ts — transparent proxy for all methods, streams
  the response, strips hop-by-hop headers, and returns 502 on upstream
  failure instead of crashing.
- app/sitemap.xml/route.ts — proxies the backend sitemap (robots.ts points
  crawlers here).

The same promoted image now adapts to whatever the backend is called in
each environment. Verified: production build succeeds with /api/[...path]
and /sitemap.xml as dynamic routes and an empty rewrites manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🤖 AI Code Review (Claude Code)

This PR replaces Next.js's build-time rewrites() proxy for /api/* and /sitemap.xml with runtime route-handler proxies that read FASTAPI_URL per request, fixing the real problem of a single promoted image having its backend hostname baked in at build time. I checked the deploy configs (docker-compose.portainer.yml, docker-compose.portainer.staging.yml, .gitea/workflows/deploy.yml) and FASTAPI_URL is correctly set as a runtime env var in both staging and prod compose files, so the fix should actually take effect. The implementation is solid overall (correct duplex streaming, no-store caching, hop-by-hop header stripping on the response side) with only a few edge-case correctness gaps.

🟡 Minor

  • nextjs-app/app/api/[...path]/route.ts: Catch-all path segments are already URI-decoded by Next.js before reaching the handler, but they're rejoined with '/' and concatenated into the target URL string without re-encoding. A segment containing a literal '#' (from an originally percent-encoded request path) will be parsed by fetch's URL constructor as the start of a fragment, silently truncating the proxied path; a segment containing '?' will inject an unintended query string that gets merged with the real req.nextUrl.search. This can misroute or truncate requests to the backend for any path containing those reserved characters.
  • nextjs-app/app/api/[...path]/route.ts: A single-bracket catch-all ([...path]) requires at least one path segment, whereas the old next.config.js rewrite rule source: '/api/:path*' also matched the bare /api path (zero segments). A request to exactly /api will now 404 in Next.js instead of being proxied to the backend, a small behavior regression versus the previous rewrite.
  • nextjs-app/app/api/[...path]/route.ts: Header hygiene is asymmetric: the response strips content-encoding/content-length/transfer-encoding/connection, but the forwarded request only strips 'host' and 'connection'. The client's original content-length/transfer-encoding headers are passed straight through even though the body is being re-streamed via duplex: 'half', which can conflict with undici's own framing for non-trivial request bodies (e.g. chunked client uploads).
## 🤖 AI Code Review (Claude Code) This PR replaces Next.js's build-time `rewrites()` proxy for /api/* and /sitemap.xml with runtime route-handler proxies that read FASTAPI_URL per request, fixing the real problem of a single promoted image having its backend hostname baked in at build time. I checked the deploy configs (docker-compose.portainer.yml, docker-compose.portainer.staging.yml, .gitea/workflows/deploy.yml) and FASTAPI_URL is correctly set as a runtime env var in both staging and prod compose files, so the fix should actually take effect. The implementation is solid overall (correct duplex streaming, no-store caching, hop-by-hop header stripping on the response side) with only a few edge-case correctness gaps. ### 🟡 Minor - **nextjs-app/app/api/[...path]/route.ts**: Catch-all `path` segments are already URI-decoded by Next.js before reaching the handler, but they're rejoined with '/' and concatenated into the target URL string without re-encoding. A segment containing a literal '#' (from an originally percent-encoded request path) will be parsed by fetch's URL constructor as the start of a fragment, silently truncating the proxied path; a segment containing '?' will inject an unintended query string that gets merged with the real `req.nextUrl.search`. This can misroute or truncate requests to the backend for any path containing those reserved characters. - **nextjs-app/app/api/[...path]/route.ts**: A single-bracket catch-all (`[...path]`) requires at least one path segment, whereas the old `next.config.js` rewrite rule `source: '/api/:path*'` also matched the bare `/api` path (zero segments). A request to exactly `/api` will now 404 in Next.js instead of being proxied to the backend, a small behavior regression versus the previous rewrite. - **nextjs-app/app/api/[...path]/route.ts**: Header hygiene is asymmetric: the response strips content-encoding/content-length/transfer-encoding/connection, but the forwarded request only strips 'host' and 'connection'. The client's original content-length/transfer-encoding headers are passed straight through even though the body is being re-streamed via `duplex: 'half'`, which can conflict with undici's own framing for non-trivial request bodies (e.g. chunked client uploads).
tudor merged commit 47335fcda0 into main 2026-07-06 10:11:39 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tudor/school_compare#14