How Modern JavaScript Bundlers Broke a 50-Year-Old Unix Convention (And What To Do About It)

Environment variables are simple. You set them, your process reads them, your app behaves differently. This pattern is older than most of us. It’s rule III of the Twelve-Factor App. It works for Python. It works for Go. It works for Java. It works for literally every server-side language in existence.

It does not work for Next.js.

Or Vite. Or Create React App. Or Nuxt. Or any modern JavaScript framework that bundles client-side code. And the way it fails is subtle enough that you won’t notice until you’re debugging a production deployment at 2am, wondering why your acceptance environment is making API calls to your production server.


The Bait and Switch

Here’s what every Next.js tutorial tells you:

# .env.local
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
// Works great in development!
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

You read this, you nod, you think: “environment variables, sure, I know how those work.” And in development, they do work. npm run dev reads your .env.local file, your app picks up the values, everything is fine.

Then you build a Docker image:

RUN npm run build

And something sinister happens. Webpack (or Turbopack, or whatever bundler Next.js is using this week) sees process.env.NEXT_PUBLIC_API_URL and replaces it with a string literal. Your compiled JavaScript doesn’t contain a reference to an environment variable. It contains:

const apiUrl = "http://localhost:8000/api/v1";

The variable is gone. The environment is gone. What you have is a hardcoded string baked into a minified JavaScript file buried somewhere in .next/static/chunks/app/page-a3f8b2c1.js. No amount of docker run -e NEXT_PUBLIC_API_URL=https://production.example.com will change it. The value was decided at build time and it is final.

This is not a bug. This is by design. The bundler documentation calls it “static replacement” or “define substitution.” Webpack’s DefinePlugin, Vite’s define, esbuild’s --define — they all do the same thing. They find process.env.SOMETHING in your source code and swap it for the literal value before the code ships to the browser.

The rationale is reasonable: browsers don’t have environment variables. There’s no process.env in the browser. So the bundler resolves the value at build time and embeds it. Dead code elimination can then remove branches like if (process.env.NODE_ENV === 'development') entirely. The resulting bundle is smaller and faster.

The cost is that your Docker image is now welded to a single environment.


Why This Actually Hurts

If you’re deploying a personal project to a single server, this doesn’t matter. Build with the right values, deploy, done.

But if you’re doing anything resembling professional software delivery, you hit walls fast:

One image, one environment. You can’t build once and promote the same image through staging, acceptance, and production. Every environment needs its own build. This directly violates the Twelve-Factor App methodology and most CI/CD best practices.

Build arguments leak. Your production API URL, your analytics keys, your feature flag endpoints — they’re all baked into the JavaScript bundle. Anyone can open DevTools, look at the source, and see every NEXT_PUBLIC_* value you set at build time. This is “public” by design, but it means you can’t change them without rebuilding and redeploying.

CI pipelines multiply. Instead of one build step followed by multiple deploy steps, you need N build steps — one per environment. Each build takes minutes (Next.js builds are not fast). Your deployment pipeline is now 3x slower because you’re compiling the same code three times with different env vars.

Rolling back becomes a rebuild. If you need to roll back to a previous version with a different API URL, you can’t just re-deploy an old image. You need to rebuild it.

The Python equivalent would be if Flask decided to replace os.environ.get('DATABASE_URL') with the literal connection string at import time. You’d file a bug report. In the JavaScript ecosystem, it’s a “feature.”


The Solutions, Ranked

I’ve spent too long dealing with this. Here are the approaches I’ve seen, from worst to best.

1. Just rebuild per environment (the surrender)

# CI pipeline
build-staging:
  script: NEXT_PUBLIC_API_URL=https://staging.example.com npm run build
build-production:
  script: NEXT_PUBLIC_API_URL=https://api.example.com npm run build

This is what most teams do. It works. It’s wasteful. It’s the default because the framework gives you no better option out of the box.

Verdict: Correct but expensive. You’re paying with CI minutes and deployment speed for a problem the framework created.

2. sed the built files at container start (the hack)

Build with placeholder values, then find-and-replace them in the .next/ output at container startup:

#!/bin/sh
# entrypoint.sh
find /app/.next -type f -name "*.js" -exec \
  sed -i "s|__NEXT_PUBLIC_API_URL__|${NEXT_PUBLIC_API_URL}|g" {} \;
exec "$@"
ENV NEXT_PUBLIC_API_URL=__NEXT_PUBLIC_API_URL__
RUN npm run build
ENTRYPOINT ["./entrypoint.sh"]
CMD ["node", "server.js"]

This is clever. It’s also terrifying. You’re doing regex replacement on minified JavaScript files. The content hashes in the filenames no longer match the content. Browsers that cached the old chunks will serve stale JavaScript with the old values. If your placeholder string appears in actual content (unlikely but possible), you’ll corrupt your bundle.

Multiple blog posts recommend this approach. I’ve seen it work. I’ve also seen it break in ways that are extremely hard to debug.

Verdict: Don’t. The cache invalidation problem alone should disqualify it.

3. API route that serves config (the over-engineering)

// app/api/config/route.ts
export function GET() {
  return Response.json({
    apiUrl: process.env.NEXT_PUBLIC_API_URL,
    wsUrl: process.env.NEXT_PUBLIC_WS_URL,
  });
}
// Client-side
const config = await fetch('/api/config').then(r => r.json());

Your app makes an HTTP request to itself to learn its own configuration. On every page load. Before it can make any other API call.

Verdict: Works, but adds latency and a loading state to every page. You’ve traded a build problem for a runtime performance problem.

4. Server Component injects into React Context (the “correct” way)

// app/layout.tsx (Server Component)
export default function Layout({ children }) {
  return (
    <ConfigProvider apiUrl={process.env.NEXT_PUBLIC_API_URL}>
      {children}
    </ConfigProvider>
  );
}

The root layout runs on the server where process.env actually works at runtime. It reads the real environment variable and passes it to a client-side Context provider.

Pros: Pure React. No hacks. Server-side rendering works. Cons: Your root layout must be a Server Component. Every consumer needs a useConfig() hook. If your layout is already 'use client' (like ours was), this requires a major refactor. And you need dynamic = 'force-dynamic' to prevent Next.js from statically optimizing the layout and — you guessed it — baking in the values at build time.

Verdict: The Right Answer if you’re starting fresh. Expensive to retrofit.

5. window.__ENV injection via Docker entrypoint (the pragmatic fix)

Generate a small JavaScript file at container start that sets window.__ENV from the container’s actual environment variables. Load it synchronously in <head> before React hydrates.

#!/bin/sh
# docker-entrypoint.sh
cat > /app/public/__env.js <<EOF
window.__ENV = {
  NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL}",
  NEXT_PUBLIC_WS_URL: "${NEXT_PUBLIC_WS_URL}",
};
EOF
exec "$@"
// app/layout.tsx
<head>
  <script src="/__env.js" />
</head>
// lib/config.ts
export function getApiUrl(): string {
  // 1. Runtime override (Docker)
  if (typeof window !== 'undefined' && window.__ENV?.NEXT_PUBLIC_API_URL) {
    return window.__ENV.NEXT_PUBLIC_API_URL;
  }
  // 2. Build-time fallback (local dev)
  return process.env.NEXT_PUBLIC_API_URL || '';
}

The resolution order is intentional: runtime override wins over build-time defaults. In local dev, window.__ENV is empty (a placeholder file ships with window.__ENV = {}), so process.env values from .env.local take effect. In Docker, the entrypoint writes the real values.

This is what the next-runtime-env library does internally. Their PublicEnvScript component renders an inline <script> that assigns all NEXT_PUBLIC_* server-side env vars to window.__ENV. Same pattern, packaged as an npm dependency.

Verdict: This is what we use. No external dependency, cache-safe (the script file is static, not content-hashed), works with 'use client' layouts, and the entrypoint adds < 1ms to container startup.


The Implementation

The complete solution has five parts:

1. Docker entrypoint (scripts/docker-entrypoint.sh) — reads NEXT_PUBLIC_* env vars and writes them to public/__env.js:

#!/bin/sh
set -e
ENV_FILE="/app/public/__env.js"
echo "window.__ENV = {" > "$ENV_FILE"
[ -n "$NEXT_PUBLIC_API_URL" ] && echo "  NEXT_PUBLIC_API_URL: \"$NEXT_PUBLIC_API_URL\"," >> "$ENV_FILE"
[ -n "$NEXT_PUBLIC_WS_URL" ] && echo "  NEXT_PUBLIC_WS_URL: \"$NEXT_PUBLIC_WS_URL\"," >> "$ENV_FILE"
echo "};" >> "$ENV_FILE"
exec "$@"

2. Dockerfile — sets the entrypoint before the CMD:

COPY scripts/docker-entrypoint.sh /app/docker-entrypoint.sh
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.js"]

3. Layout (app/layout.tsx) — loads the script synchronously:

<head>
  <script src="/__env.js" />
</head>

4. Config module (lib/config.ts) — checks window.__ENV first:

export function getRuntimeEnv(key: string): string | undefined {
  if (typeof window !== 'undefined') {
    const env = (window as any).__ENV;
    if (env?.[key]) return env[key];
  }
  // Webpack replaces these with literal strings at build time.
  // They serve as defaults when __ENV is absent (local dev).
  switch (key) {
    case 'NEXT_PUBLIC_API_URL': return process.env.NEXT_PUBLIC_API_URL;
    case 'NEXT_PUBLIC_WS_URL': return process.env.NEXT_PUBLIC_WS_URL;
    default: return undefined;
  }
}

export function getApiUrl(): string {
  return getRuntimeEnv('NEXT_PUBLIC_API_URL') || `${location.protocol}//${location.host}`;
}

5. Placeholder (public/__env.js) — checked into the repo for local dev:

window.__ENV = {};

That’s it. Same image, any environment:

# Acceptance
docker run -e NEXT_PUBLIC_API_URL=https://acc.example.com/api/v1 myapp

# Production
docker run -e NEXT_PUBLIC_API_URL=https://api.example.com/api/v1 myapp

Why This Problem Exists

The root cause isn’t really webpack or Next.js being unreasonable. The root cause is that the browser is not a server. There is no process.env in the browser. There is no way to read environment variables in client-side JavaScript.

Every solution to this problem is a workaround for that fundamental constraint. The bundler’s “bake it in” approach is the simplest workaround — it just happens to be the one with the worst operational characteristics.

What we really want is a browser-native mechanism for applications to declare “I need these configuration values at runtime” and for the hosting environment to provide them. Something like:

<meta name="env" content="API_URL" />

…that the server could intercept and fill in. But that doesn’t exist, so we write shell scripts that generate JavaScript files inside Docker containers. Modern web development.


The Takeaway

If you’re using Next.js (or Vite, or any framework that bundles client-side code) and deploying to Docker:

  1. Don’t trust process.env in client code. It’s not an environment variable. It’s a string literal that was decided at build time.

  2. Build once, configure at runtime. Use window.__ENV injection or next-runtime-env to decouple your build from your deployment.

  3. Centralize your config. Don’t scatter process.env.NEXT_PUBLIC_* across 50 files. Put it behind a getter function that knows the resolution order. When you need to change the mechanism, you change one file.

  4. Don’t sed your built JavaScript. It works until it doesn’t, and when it doesn’t, you’ll have a very bad day.

  5. The switch statement is intentional. Listing each env var explicitly (instead of return process.env[key]) prevents webpack from optimizing away the entire function. If you write process.env[key] with a dynamic key, webpack can’t resolve it and may throw or return undefined. The explicit switch ensures each process.env.NEXT_PUBLIC_* reference is statically analyzable.

Environment variables are simple. JavaScript bundlers made them complicated. The fix is 30 lines of shell script and a config module that knows the difference.


This post was written after spending an unreasonable amount of time deploying the same Next.js app to two environments and discovering that the acceptance server was calling the production API. If this saved you a few hours, it was worth the rant.