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…
Ruledeploy
About
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.
Skill Content
This is the raw markdown that gets installed as a Claude Code rule.
# 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.
```javascript
// 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.
```typescript
// 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 under `spec.dependencies` so the builder injects `{SERVICE}_URL`:
```yaml
spec:
dependencies:
- service: my-api # injects MY_API_URL into the frontend pod
transport: direct # same-org UI/API split — the one valid use of `direct`.
# Omit it to get the `janus` default (metered, JWT-verified).
```
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:
| 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_*`.
## 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.
## `public/` — you no longer need one
The generated Dockerfile copies `public/` into the runtime image. It also **creates the
directory in the builder stage first** (`RUN mkdir -p /app/public`), so a Next.js service
that ships no static assets from disk builds fine without one.
This was not always true. Until 2026-08-21 the copy was unconditional, and a service with
no `public/` failed the image build with:
```
COPY --from=builder /app/public ./public
ERROR: failed to calculate checksum of ref ...: "/app/public": not found
```
— after a full dependency install and compile, which made it an expensive way to learn that
an optional Next.js directory was mandatory on Tawa. Twenty-six services had worked around
it with a committed `public/.gitkeep`.
**If your repo has a `public/.gitkeep` and nothing else in `public/`,** it is now dead weight
and safe to delete. There is no rush — an empty directory costs nothing — but nothing new
needs to add one.
## Common Mistakes
| Wrong | Right |
|-------|-------|
| Committing `public/.gitkeep` to satisfy the build | Not needed — the builder creates it |
| `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 |
Install
Copy the skill content and save it to:
~/.claude/rules/tawa-nextjs.mdComing soon via CLI:
tawa chaac install tawa-nextjsDetails
- Format
- Rule
- Category
- deploy
- Version
- 1.0.84998
- Tokens
- ~1,507
- Updated
- 2026-08-28
platformnextjsfrontendenv-varsproxyruntimebuild-time