🚀 Executive Summary

TL;DR: Next.js’s build-time inlining of `NEXT_PUBLIC_` environment variables forces application rebuilds for each environment, violating the ‘Build Once, Deploy Anywhere’ principle for containerized CI/CD. The solution involves implementing runtime environment variable injection to decouple the build artifact from environment-specific configurations, ensuring a single Docker image can be promoted across all stages.

🎯 Key Takeaways

  • Next.js optimizes `NEXT_PUBLIC_` environment variables by inlining them into JavaScript bundles at build time, making them static and problematic for multi-environment container deployments.
  • Runtime configuration can be achieved through an entrypoint shell script that replaces placeholders in built JavaScript files, or by fetching configuration from a dedicated API endpoint after the application loads.
  • The `output: ‘standalone’` mode in Next.js allows for maximum decoupling by separating static assets from the Node.js server, enabling advanced configuration injection strategies at the edge (e.g., via Nginx).

What’s one thing you always do in a new Next.js project that isn’t in the official docs?

Stop rebuilding your Next.js Docker image for every environment. Here’s how senior engineers handle runtime environment variables—the tricks you won’t find in the official docs.

The Unofficial Next.js Commandment: Thou Shalt Not Rebuild For Every Environment

I remember it like it was yesterday. It was 2 AM, the “emergency hotfix” pipeline just went green, and we hit the big red button to deploy to production. Five minutes later, our lead SRE is on Slack: “Darian, why are production users writing data to the staging-db-01 replica?” My blood ran cold. The code fix was perfect, but our Docker build process had permanently baked the staging NEXT_PUBLIC_API_URL into the JavaScript files. We had built our image against staging, promoted that same image to prod, and in doing so, pointed our entire user base at a test database. This is the moment every team deploying Next.js in containers eventually faces, and it’s born from a feature, not a bug.

The “Why”: Build-Time vs. Runtime

The core of the problem is simple: Next.js is smart. To optimize performance, any environment variable prefixed with NEXT_PUBLIC_ is inlined, or “baked into,” the JavaScript bundles at build time. This means the value of NEXT_PUBLIC_API_URL becomes a hardcoded string in the static files sent to the browser.

This is a fantastic optimization for simple deployments, but it directly conflicts with the single most important principle of modern containerized CI/CD: Build Once, Deploy Anywhere. You should build a single Docker image and promote that exact same artifact through your environments (Dev → Staging → Prod). With build-time variables, you’re forced to rebuild your entire application for each environment, which is slow, error-prone, and just plain wrong.

So, how do we fix it? We need a way to inject these variables at runtime, when the container starts. Here are three ways we’ve done it at TechResolve, from the quick hack to the architecturally sound solution.

Solution 1: The “It’s 2 AM and This Needs to Work” Entrypoint Script

This is the classic, gritty, in-the-trenches solution. It’s a hack, but it’s a beautiful hack that has saved countless production rollouts. The idea is to replace placeholder values in your code with real environment variables when the container starts up.

Step 1: Use Placeholders in Your Code

Instead of using process.env.NEXT_PUBLIC_API_URL directly, you use a unique placeholder that won’t get mangled during the build.

const apiUrl = "RUNTIME_NEXT_PUBLIC_API_URL";

Step 2: Create an Entrypoint Script

This shell script will run before your application starts. It scans the built JavaScript files and replaces your placeholder with the actual environment variable passed to the Docker container.

Create a file named entrypoint.sh:

#!/bin/sh

# Get the list of JS files from the manifest
# This is more reliable than globbing for *.js
files=$(grep -oE '\/[^"]+\.js' .next/build-manifest.json | sed 's/^/\.next/')

# Find and replace the placeholder in all JS files
for file in $files
do
  echo "Processing $file ..."
  sed -i "s|RUNTIME_NEXT_PUBLIC_API_URL|${NEXT_PUBLIC_API_URL}|g" "$file"
done

echo "Starting Next.js server..."
exec "$@"

Step 3: Modify Your Dockerfile

Finally, update your Dockerfile to use this script.

# (Assuming you have a standard Next.js Dockerfile build process above this)

# Final stage
FROM base AS runner
WORKDIR /app

# Copy the entrypoint script and make it executable
COPY --from=builder /app/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh

# Copy over the rest of the app
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json

# Run the app using the entrypoint script
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["node_modules/.bin/next", "start"]

Warning: This method is effective but brittle. A future Next.js update could change the build output structure and break your script. Use this when you need a fast solution for an existing project, but consider Solution 2 for new ones.

Solution 2: The Architect’s Choice – A Runtime Configuration Endpoint

This is the clean, long-term solution. Instead of injecting variables into static files, the application fetches its configuration from an API endpoint after it loads in the browser. This completely decouples your frontend build from your environment configuration.

Step 1: Create a Configuration API Route

Create a file at /pages/api/config.ts (or /app/api/config/route.ts in the App Router). This endpoint’s only job is to expose your public environment variables.

// pages/api/config.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  res.status(200).json({
    apiUrl: process.env.NEXT_PUBLIC_API_URL,
    // Add other public variables here
  });
}

Step 2: Fetch and Provide the Config to Your App

In your main app layout (_app.tsx), fetch this configuration and make it available to all your components using a React Context.

// A simple example in _app.tsx
import { AppProps } from 'next/app';
import { useEffect, useState } from 'react';
import { AppConfig, ConfigContext } from '../contexts/ConfigContext'; // Assume you created this context

function MyApp({ Component, pageProps }: AppProps) {
  const [config, setConfig] = useState<AppConfig | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/config')
      .then((res) => res.json())
      .then((configData) => {
        setConfig(configData);
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <div>Loading Configuration...</div>;
  }

  return (
    <ConfigContext.Provider value={config}>
      <Component {...pageProps} />
    </ConfigContext.Provider>
  );
}

export default MyApp;

Pro Tip: This approach is fantastic. The only downside is a slight delay on initial load while the config is fetched. You can mitigate this by rendering a loading skeleton or embedding the initial config in the page’s HTML via `getServerSideProps` in your top-level pages.

Solution 3: The “We Mean Business” Standalone Output

For maximum control and decoupling, Next.js offers a `standalone` output mode. This is less of a direct “fix” and more of a philosophical shift in how you serve your application.

Step 1: Enable Standalone Output

In your next.config.js, add one line:

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  output: 'standalone', // This is the magic line
};

module.exports = nextConfig;

When you run next build, Next.js will create a .next/standalone directory with a minimal Node.js server and only the necessary dependencies. It also copies the .next/static and public folders separately.

Step 2: Serve Statically, Run Dynamically

This mode lets you treat your app as two distinct pieces:

  1. The Static Assets: The JavaScript, CSS, and images in .next/static. You can serve these from a simple, high-performance web server like Nginx or from a CDN.
  2. The Server: The Node.js server in the standalone directory that handles SSR, API routes, and image optimization.

With this separation, you can use your web server (Nginx) to inject the configuration into the initial HTML document before it’s ever sent to the client, solving the problem at the edge.

Comparison at a Glance

Method Complexity Reliability Best For
1. Entrypoint Script Low Medium (Brittle) Existing projects needing a quick, effective fix.
2. Config Endpoint Medium High New projects where clean architecture is a priority.
3. Standalone Output High Very High Large-scale, performance-critical applications with custom infrastructure.

Final Thoughts

That 2 AM incident taught us a valuable lesson: a feature that helps in development can become a liability in a modern production environment. Don’t fight the framework, but don’t let it dictate a flawed deployment strategy either. By understanding why Next.js behaves the way it does, you can choose the right pattern to take back control of your environments. Stop rebuilding, start shipping.

Darian Vance - Lead Cloud Architect

Darian Vance

Lead Cloud Architect & DevOps Strategist

With over 12 years in system architecture and automation, Darian specializes in simplifying complex cloud infrastructures. An advocate for open-source solutions, he founded TechResolve to provide engineers with actionable, battle-tested troubleshooting guides and robust software alternatives.


🤖 Frequently Asked Questions

❓ Why do Next.js `NEXT_PUBLIC_` variables cause issues in containerized deployments?

Next.js inlines `NEXT_PUBLIC_` environment variables into JavaScript bundles at build time for performance optimization. This makes them static and environment-specific, conflicting with the ‘Build Once, Deploy Anywhere’ principle for containerized CI/CD, which requires a single, immutable artifact for all environments.

❓ What are the trade-offs between the entrypoint script and a runtime configuration API endpoint for Next.js?

The entrypoint script is a low-complexity, quick fix that is effective but brittle, potentially breaking with Next.js updates. The runtime configuration API endpoint is a more robust, architecturally sound solution with high reliability for new projects, though it introduces a slight initial load delay while fetching the configuration.

❓ What is a common implementation pitfall when using the entrypoint script method for runtime environment variables in Next.js?

A common pitfall is the brittleness of the entrypoint script; future Next.js updates might alter the build output structure, causing the script to fail in finding and replacing placeholders. This requires vigilance and potential script updates with each Next.js version change.

Leave a Reply

Discover more from TechResolve - SaaS Troubleshooting & Software Alternatives

Subscribe now to keep reading and get access to the full archive.

Continue reading