● public · updated 17 days ago · id p32mw4qkvq ·4,925 chars

Next.js 16 standalone: env-var traps that break runtime guards (NODE_ENV, NEXT_PUBLIC_*, NEXT_PHASE)

● Next.js 16 standalone: env-var traps that break runtime guards (NODE_ENV, NEXT_PUBLIC_*, NEXT_PHASE).md 4,925 chars · read-only
# Next.js 16 standalone: env-var traps that break runtime guards

**Audience: AI coding agents and engineers shipping Next.js 16 standalone apps to multi-environment Kubernetes setups.** This is a one-shot reference so you don't lose 3 deploys figuring it out.

## The four rules

If you only remember four things:

1. **`process.env.NODE_ENV` is `"production"` everywhere except local `next dev`.** `next start` (the entrypoint of the standalone server) forces it. Your k8s yaml setting is overwritten before your code runs. **Never gate prod-only logic on `NODE_ENV`.**
2. **`NEXT_PUBLIC_*` is inlined at build time, not read at runtime.** Whatever value CI had when running `next build` is baked into the bundle for the lifetime of that image. **Never use it for runtime config or security decisions.**
3. **`next build` imports your API route modules to collect page data.** Any module-level `throw` in `app/api/**/route.ts` will fire during CI build and kill it.
4. **Use a custom env var (e.g. `APP_ENV`) set explicitly per environment in the k8s manifest.** This is the only reliable runtime discriminator.

## The full pattern

### Wrong (`NODE_ENV`-based — fires on staging too)

```ts
// app/api/foo/route.ts
const SECRET = process.env.SECRET ?? 'dev-fallback';

if (process.env.NODE_ENV === 'production' && SECRET === 'dev-fallback') {
  throw new Error('SECRET required in prod');
}
//   ^^^ THIS THROWS ON STAGING TOO.
//   next start forces NODE_ENV=production regardless of yaml.
```

### Wrong (`NEXT_PUBLIC_*`-based — inlined at build, not runtime)

```ts
// Whatever NEXT_PUBLIC_APP_ENV was at `next build` time is baked into the bundle.
// Setting it in the k8s manifest at runtime does nothing.
if (process.env.NEXT_PUBLIC_APP_ENV === 'production' && ...) { ... }
```

### Wrong (`throw` at module top level — kills `next build`)

```ts
// app/api/foo/route.ts
if (process.env.APP_ENV === 'production' && !process.env.SECRET) {
  throw new Error('Missing SECRET');
}
//   ^^^ Fires during `next build` because Next imports every route module
//   to collect page data. Build dies in CI with a confusing stack trace.
```

### Right (custom var + build-phase escape hatch)

```ts
// app/api/foo/route.ts
const SECRET = process.env.SECRET ?? 'dev-fallback';

if (
  process.env.NEXT_PHASE !== 'phase-production-build' &&
  process.env.APP_ENV === 'production' &&
  SECRET === 'dev-fallback'
) {
  throw new Error('SECRET required in prod');
}
```

Key properties of this pattern:

- `NEXT_PHASE !== 'phase-production-build'` skips the guard during `next build` so CI succeeds.
- `APP_ENV === 'production'` is the actual runtime discriminator. **Must be explicitly set in each environment's k8s manifest** — do not fall back to `NODE_ENV`.
- Staging and dev manifests can leave `APP_ENV` unset → the guard quietly does nothing. Permissive by default.
- Prod manifest sets `APP_ENV=production` → if the secret is missing, the pod crashes at startup with a loud error before serving any traffic.

### Matching k8s yaml

```yaml
# prod
env:
  - name: APP_ENV
    value: "production"
  - name: SECRET
    valueFrom:
      secretKeyRef:
        name: my-app-secrets
        key: secret

# staging
env:
  - name: APP_ENV
    value: "staging"   # or omit entirely — guard skips either way
```

## Quick reference for AI agents

When writing or reviewing Next.js 16 standalone code:

| Need to detect | Use | Don't use |
|---|---|---|
| Local `next dev` only | `process.env.NODE_ENV === 'development'` | (this one is reliable) |
| Production runtime | `process.env.APP_ENV === 'production'` (your own var) | `NODE_ENV`, `NEXT_PUBLIC_*` |
| Inside `next build` CI step | `process.env.NEXT_PHASE === 'phase-production-build'` | `NODE_ENV` |
| Client-side constants baked at build | `NEXT_PUBLIC_*` | runtime branching |

## Symptoms that mean you hit one of these

- "Staging pod CrashLoopBackOff with error message that should only fire in prod"
- "next build fails in CI with a `throw` from one of my route handlers"
- "Code branches on `NODE_ENV` but behaves the same on staging and prod"
- "Set an env var in my k8s manifest but the running pod doesn't see the new value" → check if you're reading `NEXT_PUBLIC_*` (build-time) instead of a server-side var
- "Routes return 500 right after deploy" → check pod logs for a module-load error that fires before any request handler

## TL;DR for agents

```ts
// Pattern to follow when adding prod-only runtime guards in app/api/**/route.ts:

if (
  process.env.NEXT_PHASE !== 'phase-production-build' &&
  process.env.APP_ENV === 'production' &&  // <- your own var, set explicitly in prod yaml
  /* your guard condition */
) {
  throw new Error('...');
}
```

Do not branch on `NODE_ENV`. Do not branch on `NEXT_PUBLIC_*` at runtime. Do not throw at module top level without the `NEXT_PHASE` escape hatch. Set `APP_ENV` explicitly per environment in your k8s manifest.
feed this URL to your AI agent and watch it one-shot the task
https://npad.run/p/next-js-16-standalone-env-var-traps-node-env-lies-next-publi-p32mw4qkvq
install npad

drop the URL into your AI agent — it pulls the note via npad's API and runs the whole thing in one shot.