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 — MY_API_URL is not set at build time,
// this always falls back to localhost in production
async rewrites() {
  const apiUrl = process.env.MY_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. Route handlers run at request time on the live pod — they read env vars correctly.

// app/api/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server'

const API_URL = process.env.MY_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. If you have app/api/health/route.ts, it is served by Next.js and is not 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 points to the Janus internal proxy. Do not hardcode it.

What IS Safe to Read at Build Time

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

Common Mistakes

WrongRight
process.env.MY_API_URL in rewrites()Catch-all API route proxy
NEXT_PUBLIC_API_URL for an internal Janus URLNever 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 varies per environmentCatch-all API route proxy

Last updated: March 11, 2026