Next.js on Tawa

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.

The Rule

Never use rewrites() in next.config.js to 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*` }]
}

Correct Pattern: Catch-All API Route Proxy

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.

catalog-info.yaml

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.

What IS Safe to Read at Build Time

Some Next.js features are designed for build-time values:

FeatureTimingUse for
rewrites() destinationBuild timeStatic external URLs only (e.g. https://api.example.com)
NEXT_PUBLIC_*Build timeClient-side public values (baked into JS bundle)
Route handler bodyRuntimeAll platform-injected env vars (BINDDESK_API_URL, BIO_CLIENT_ID, etc.)
Server ComponentsRuntimeAll platform-injected env vars
next.config.js env:Build timeStatic 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_*.

Build-time vars via 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-vars keys are written (public-by-design). Secrets and internalDependencies/databases/auth vars 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.

Common Mistakes

WrongRight
process.env.MY_API_URL in rewrites()Catch-all API route proxy
NEXT_PUBLIC_API_URL=http://... for internal URLsNever 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 environmentCatch-all API route proxy

Last updated: July 15, 2026