DEV BOX

'26/11/02 UPDATE

WEB / NEXT.JS / FIELD NOTES

I changed the environment variable and restarted. Why does my Next.js page still show the old URL?

You change NEXT_PUBLIC_API_BASE and restart next start, yet the page still calls the old API URL. Building with value A and starting with value B on Next.js 16.4.3 shows that NEXT_PUBLIC_ variables and prerendered pages keep the build value, and only a server variable without NEXT_PUBLIC_ reads the value at request time. With real runs, this note shows how to tell which values are frozen at build and three ways to fix it.

Say you take the Docker image built for staging, put it on the production server unchanged, and restart next start with only NEXT_PUBLIC_API_BASE switched to the production URL. The server comes up with the new value, yet the page still calls the staging API. The setting is not wrong: the value was already written into the code as plain text when next build ran. After this note you will be able to tell, for every place your code reads an environment variable, whether that value is frozen at build or read fresh on each request, and decide where a value that must differ per environment should be read.

The example was run on 2026-10-25 on macOS 27.0.1 with Node.js v24.21.0 and Next.js 16.4.3 installed from npm. A plain next build built with Turbopack; for comparison the same steps were also run with a --webpack build. The .test top-level domain is reserved for testing (RFC 2606), and build.example.test and runtime.example.test are fictional hosts.

Build with value A, start with value B

The App Router app has three files: an empty layout (app/layout.js), a page rendered on each request, and a Client Component inside that page. The page reads two variables, and the Client Component reads the one with the NEXT_PUBLIC_ prefix.

// app/page.js
import { connection } from 'next/server';
import ClientBase from './client-base';

export default async function Page() {
  await connection(); // render this page on each request
  return (
    <main>
      <p>{`server NEXT_PUBLIC_API_BASE=${process.env.NEXT_PUBLIC_API_BASE}`}</p>
      <p>{`server API_BASE=${process.env.API_BASE}`}</p>
      <ClientBase />
    </main>
  );
}

// app/client-base.js
'use client';
export default function ClientBase() {
  return <p>{`client NEXT_PUBLIC_API_BASE=${process.env.NEXT_PUBLIC_API_BASE}`}</p>;
}

Both variables were set to https://build.example.test for the build, and the same build output was then started with both set to https://runtime.example.test. To compare with a page made ahead of time at build, a /static page reads only API_BASE, without connection().

Next.js 16.4.3 output. next build (Turbopack, exit 0) with NEXT_PUBLIC_API_BASE and API_BASE set to https://build.example.test: /, /api/config, /lookup and /props are ƒ (rendered per request), /static is ○ (prerendered). After next start with both set to https://runtime.example.test, curl /: server NEXT_PUBLIC_API_BASE=https://build.example.test, server API_BASE=https://runtime.example.test, client NEXT_PUBLIC_API_BASE=https://build.example.test. curl /static: static API_BASE=https://build.example.test. In .next/static the only file holding build.example.test is chunks/3xaa8z2t_e59x.js, with the string "client NEXT_PUBLIC_API_BASE=https://build.example.test". Files holding runtime.example.test: 0.
Actual output of a build with the build URL and a start with the runtime URL. NEXT_PUBLIC_ variables showed the build URL on both the server and the browser side, and the prerendered page kept its build value too.

Even on the page rendered per request, NEXT_PUBLIC_API_BASE showed the build URL on both the server side and the client side. Only API_BASE, the name without NEXT_PUBLIC_, read the URL given at start. Opening the same page in a Chromium-based browser (Chrome 152) and reading it after hydration gave the same three lines, with no console warning. The build folder shows why: the JS file sent to the browser contains the build URL as a string, and the URL given at start appears nowhere in .next.

next build writes NEXT_PUBLIC_ values into the code

The Next.js environment variables guide calls this inlining. For variables whose names start with NEXT_PUBLIC_, it replaces every process.env.NEXT_PUBLIC_… reference with the value from the environment where next build ran. That covers code on the Node.js side as well as JavaScript sent to the browser, which is why the Server Component also showed the build value. The guide states that once built, "your app will no longer respond to changes to these environment variables", and warns that if one Docker image is deployed to several environments, every NEXT_PUBLIC_ variable is frozen at its build-time value.

DEV NOTES

What next build freezes, and what is read per request

Built with value A, started with value B: what each read showed

  • Code sent to the browser

    client … = build.example.test

    • process.env.NEXT_PUBLIC_* becomes the build value
    • The URL stays in the JS file as plain text
  • NEXT_PUBLIC_ in server code

    server NEXT_PUBLIC_… = build

    • The same reference is replaced on the server
    • The value given to next start is not used
  • Pages prerendered at build

    static API_BASE = build

    • No request-time API such as connection()
    • The HTML made at build is served as is
  • Server variables read per request

    server API_BASE = runtime

    • A name without NEXT_PUBLIC_
    • Read after await connection()

Next.js 16.4.3 on 2026-10-25, the same with Turbopack and webpack builds. build/runtime.example.test are fictional hosts.

NEXT_PUBLIC_ variables and prerendered pages are fixed at next build. Only a server variable without NEXT_PUBLIC_, read during a dynamic render, picked up the value at request time.

The guide also gives examples of lookups that go through a variable, process.env[varName] and const env = process.env, and says these are not inlined. A /lookup page with exactly those two forms was added to the same app. The results did not match the guide. With the Turbopack build both forms showed the build URL; with the webpack build only process.env[varName] read the URL given at start. Opening the compiled server code showed the build URL written as a string wherever the build value appeared. Only a name joined together at run time (['NEXT_PUBLIC', 'API_BASE'].join('_')) read the start value under both builds.

Next.js 16.4.3 output. Default (Turbopack) build, curl /lookup: lookup process.env[varName]=https://build.example.test, lookup env.NEXT_PUBLIC_API_BASE=https://build.example.test, lookup process.env[joined]=https://runtime.example.test. After next build --webpack (exit 0) and next start, curl /lookup: lookup process.env[varName]=https://runtime.example.test, lookup env.NEXT_PUBLIC_API_BASE=https://build.example.test, lookup process.env[joined]=https://runtime.example.test.
Actual output of the two lookups the Next.js guide says are not inlined, read on one page. The result depended on the bundler, and one of them showed the build value under both.

So do not rely on changing the lookup form to avoid inlining: even on the same 16.4.3, the outcome differed between bundlers. If a value must be read at request time, the path the guide gives is to drop NEXT_PUBLIC_ from its name.

What is read per request: server variables in a dynamic render

The guide explains that environment variables are only available on the server by default, and that you can safely read them on the server during dynamic rendering, showing process.env read after await connection(). connection() makes rendering wait for an incoming request, so the code after it runs at request time. According to the guide, this is what lets one Docker image move through several environments with different values. As the Node.js docs describe, process.env is an object containing the user environment of that process, so a value given to next start is used when server code reads this object at request time.

Dropping NEXT_PUBLIC_ is not enough on its own, though. The /static page read API_BASE and still showed the build URL, because next build renders a page that calls no request-time API, such as connection(), cookies() or headers(), to HTML in advance. In the build output table /static was marked ○ (prerendered) and / was marked ƒ (rendered per request). The response headers differed too: /static returned x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000, while / returned private, no-cache, no-store. To have a variable read at request time, first check that its page shows as ƒ in the build table.

Decide per variable: build time or request time

For each value there is one question to settle first: can it be fixed once per build, or must it differ per environment? The answer leads to one of three paths.

DEV NOTES

Three ways to handle each value

First decide: is it a build-time or a request-time value?

  • 1 · Rebuild for each environment

    NEXT_PUBLIC_API_BASE=… next build

    • Keeps NEXT_PUBLIC_ as it is
    • A new value means a new next build
  • 2 · Read on the server, pass it down

    apiBase={process.env.API_BASE}

    • A Server Component reads it after connection()
    • It goes to the Client Component as a prop
  • 3 · Your own endpoint

    GET /api/config

    • A Route Handler returns it per request
    • The browser fetches it when it starts
  • Secrets go down none of these paths

    API_KEY: server only

    • NEXT_PUBLIC_ values sit in the JS files
    • Values sent by 2 or 3 are visible too

Ways 2 and 3 were run on Next.js 16.4.3 on 2026-10-25 and showed the value given to next start.

Values that must differ per environment drop NEXT_PUBLIC_ and are read on the server at request time. Anything sent to the browser, by any route, is public; secrets stay on the server.

First, if one value per build is fine, keep NEXT_PUBLIC_ and rebuild for each environment with its value. The guide says these values need to be set appropriately when the project is built. Second, if the value must differ per environment, rename it, for example to API_BASE, read it in a Server Component after connection(), and pass it to the Client Component as a prop. Third, if the browser has to fetch it separately, build your own API that returns it, as the guide suggests. Below is the actual output of calling the second and third ways after starting the same build with the runtime URL. Turbopack and webpack builds gave the same result.

$ curl -s localhost:4392/props | grep -oE '<p>[^<]+</p>' | sed 's/<[^>]*>//g'
client apiBase prop=https://runtime.example.test
$ curl -s localhost:4392/api/config
{"apiBase":"https://runtime.example.test"}

When the endpoint is a Route Handler, calling await connection() in it first is the safer choice. The Next.js Route Handlers docs say they are not cached by default, but with Cache Components enabled a GET handler that uses no request data can be prerendered at build, and calling connection() stops that prerendering. The example app did not enable Cache Components.

Secrets go to the browser by none of these paths. As shown above, a NEXT_PUBLIC_ value sits as plain text in JS files that anyone can download. A value sent through props or an endpoint is public too, the moment it reaches the browser. Send only values that may be seen, such as an API URL, and use keys and tokens only inside server code.

The one line to remember: a NEXT_PUBLIC_ value is whatever was set at the moment next build ran. If production shows an old URL, run grep -rl <old URL> .next/static on the build output once. If a file comes back, restarting will not change it; rebuild, or move that value to a server read at request time.

THE END
RSS feedBack to the library
COMMENTS BOX

Add your perspective.

Share a question, another approach, or something you have tried.

Newest first

Checking sign-in…

Loading comments…