Next.js frontend services deployed on Tawa have a critical constraint: environment variables injected by the platform are only available at runtime, not at build time. This affects how you must proxy API calls to backend services.
Never use
rewrites()innext.config.jsto proxy to a platform-injected URL. Use a catch-all API route instead.
rewrites() are evaluated when npm run build runs inside the Docker container — before the platform has injected any runtime env vars. The destination URL gets baked into routes-manifest.json and cannot change at runtime.
// WRONG — BINDDESK_API_URL is not set at build time,
// so this always falls back to localhost:4000 in production
async rewrites() {
const apiUrl = process.env.BINDDESK_API_URL || 'http://localhost:4000'
return [{ source: '/api/:path*', destination: `${apiUrl}/api/:path*` }]
}
Create app/api/[...path]/route.ts. This is a Next.js App Router route handler — it runs on the server at request time, so it reads env vars correctly from the live pod environment.
// app/api/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server'
const API_URL = process.env.BINDDESK_API_URL || 'http://localhost:4000'
async function proxy(
req: NextRequest,
{ params }: { params: { path: string[] } }
): Promise<NextResponse> {
const path = params.path.join('/')
const { search } = new URL(req.url)
const targetUrl = `${API_URL}/api/${path}${search}`
const headers = new Headers(req.headers)
headers.delete('host')
const init: RequestInit = { method: req.method, headers }
if (req.method !== 'GET' && req.method !== 'HEAD') {
// @ts-expect-error duplex is required for streaming request bodies
init.duplex = 'half'
init.body = req.body
}
const upstream = await fetch(targetUrl, init)
return new NextResponse(upstream.body, {
status: upstream.status,
headers: upstream.headers,
})
}
export { proxy as GET, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE }
Specific routes take precedence over the catch-all — app/api/health/route.ts will still be served by Next.js and won't be forwarded upstream.
Declare the backend as an internalDependency so the builder injects {SERVICE}_URL:
spec:
internalDependencies:
- service: my-api # injects MY_API_URL (Janus proxy URL) into the frontend pod
The injected URL is the Janus internal proxy URL. Do not hardcode it.
Some Next.js features are designed for build-time values:
| Feature | Timing | Use for |
|---|---|---|
rewrites() destination | Build time | Static external URLs only (e.g. https://api.example.com) |
NEXT_PUBLIC_* | Build time | Client-side public values (baked into JS bundle) |
| Route handler body | Runtime | All platform-injected env vars (BINDDESK_API_URL, BIO_CLIENT_ID, etc.) |
| Server Components | Runtime | All platform-injected env vars |
next.config.js env: | Build time | Static values only |
Rule of thumb: Any env var from internalDependencies, databases, or auth is runtime-only. Never reference them in rewrites(), headers(), redirects(), or NEXT_PUBLIC_*.
tawa config set (NEXT_PUBLIC_* / REACT_APP_*)For values you do want baked at build time (public client-side config like a public API base),
declare them in insureco.io/env-vars and set them with tawa config set. Before docker build,
the builder materializes those declared keys from the config store into a .env.production at
the build-context root, which CRA / Vite / Next.js read during npm run build. So you do not
need to commit a .env — set the value with tawa config set NEXT_PUBLIC_FOO=... and declare it in
env-vars. See build-pipeline.md → "Build-time env injection".
Only the declared
env-varskeys are written (public-by-design). Secrets andinternalDependencies/databases/authvars are never build-baked — they stay runtime-only.
Static SPAs (CRA/Vite): for resilience, also resolve the API base at runtime from
window.location.hostname (e.g. *.example.com → https://api.example.com) so the app works even if
build-time injection is misconfigured.
| Wrong | Right |
|---|---|
process.env.MY_API_URL in rewrites() | Catch-all API route proxy |
NEXT_PUBLIC_API_URL=http://... for internal URLs | Never expose internal K8s URLs client-side |
Hardcoding K8s DNS in rewrites() | Use the injected {SERVICE}_URL in a route handler |
Using rewrites() for any URL that might change per environment | Catch-all API route proxy |
Last updated: July 15, 2026