The /api/* proxy lived in next.config.jsrewrites(), 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_URLper 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.
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.
## 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)
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>
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 main2026-07-06 10:11:39 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
The
/api/*proxy lived innext.config.jsrewrites(), whose destination is evaluated at build time and serialized intoroutes-manifest.json. CI builds the frontend image once withFASTAPI_URL=http://backend:80/api, sohttp://backendis baked in and the runtimeFASTAPI_URLenv 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 itbackend_stg, so the browser's client-side/api/*calls proxied tohttp://backendand failed:SSR was unaffected —
lib/api.tsreadsFASTAPI_URLat 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_URLper 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 clean502on upstream failure (DNS/connection) instead of an opaque crash.app/sitemap.xml/route.ts— proxies the backend's generated sitemap (robots.tspoints 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 fullnpm run buildall pass.ƒ /api/[...path]andƒ /sitemap.xmlas dynamic route handlers, androutes-manifest.jsonrewrites are now empty (nothing baked)./api/compare) should reachbackend_stgand the compare-chart E2E should pass on the gate.🤖 Generated with Claude Code
🤖 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
pathsegments 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 realreq.nextUrl.search. This can misroute or truncate requests to the backend for any path containing those reserved characters.[...path]) requires at least one path segment, whereas the oldnext.config.jsrewrite rulesource: '/api/:path*'also matched the bare/apipath (zero segments). A request to exactly/apiwill now 404 in Next.js instead of being proxied to the backend, a small behavior regression versus the previous rewrite.duplex: 'half', which can conflict with undici's own framing for non-trivial request bodies (e.g. chunked client uploads).