Tiếng Việt

Security

Environment variables in Next.js: which ones reach the browser

The NEXT_PUBLIC_ prefix is the line, but that line sits at build time, not at run time. This page builds a small Next.js 16.4.4 app once with one set of values and starts it with another, to show which value reaches the HTML and which one ends up in the JavaScript sent to the browser. Every number below comes from one run of the measuring script, and each check can fail.

The code on this page is an app that runs: 16/16 checks passed on 2026-11-14. Versions run: next@16.4.4, react@19.3.0, Node.js v26.0.0.

Two kinds of variable, and two pages that read them differently

The sample app has two pages. / is prerendered at build time. /dong awaits connection() before it reads a variable, so it reads at every request.

For a variable with the NEXT_PUBLIC_ prefix, Next.js replaces the reference with the value inside the JavaScript sent to the browser, at next build. A variable without that prefix exists on the server only. The server-client-components guide already measures both facts, so this page does not measure them again.

The sample folder commits its .env and .env.local files, because they hold made-up markers only. The Next.js docs warn against committing a real .env file.

See also: Server Components and Client Components: what runs where

app/page.tsx
import { Khach } from "./components/khach";

export default function Home() {
  return (
    <main>
      <h1>Biến môi trường</h1>
      <p>{`Máy chủ đọc BIEN_MAY_CHU: ${process.env.BIEN_MAY_CHU}`}</p>
      <p>{`.env.local ghi đè .env: ${process.env.GIA_TRI_CHON}`}</p>
      <p>{`Chỉ có trong .env: ${process.env.GIA_TRI_CHI_ENV}`}</p>
      <Khach khoa="BIEN_DONG" />
    </main>
  );
}
app/dong/page.tsx
import { connection } from "next/server";

export default async function Dong() {
  await connection();
  return (
    <main>
      <p>{`Lúc chạy, BIEN_MAY_CHU: ${process.env.BIEN_MAY_CHU}`}</p>
    </main>
  );
}
app/components/khach.tsx
"use client";

export function Khach({ khoa }: { khoa: string }) {
  const hang = "HANG";
  const varName = "NEXT_PUBLIC_VARNAME";
  return (
    <section>
      <p>{`Công khai, đọc thẳng: ${process.env.NEXT_PUBLIC_TEN}`}</p>
      <p>{`Công khai, tra cứu động: ${process.env["NEXT_PUBLIC_" + khoa]}`}</p>
      <p>{`Khóa gán vào biến hằng: ${process.env["NEXT_PUBLIC_" + hang]}`}</p>
      <p>{`Đúng ví dụ của tài liệu: ${process.env[varName]}`}</p>
    </section>
  );
}
.env
GIA_TRI_CHON=gia-tri-env-3f9a
GIA_TRI_CHI_ENV=gia-tri-chi-env-9b0e
.env.local
GIA_TRI_CHON=gia-tri-local-5c2d

`NEXT_PUBLIC_` is frozen at build time

This run builds with one value for NEXT_PUBLIC_TEN, then starts next start with a different one. The Client Component's HTML still prints the build value; the start value appears nowhere in the HTML.

The Next.js docs say the same in “Bundling Environment Variables for the Browser”: after the build the app no longer responds to changes in these variables, and they name the case of one Docker image promoted through several environments — every NEXT_PUBLIC_ value is frozen at build time. The deploy guide measures building that image; this page measures only the value a page returns.

To read a value at run time and still show it in the browser, the docs suggest serving it from your own API, on demand or at initialisation. This page did not measure that.

See also: Deploying Next.js to a VPS and Docker: standalone, reverse proxy, Server Actions

Server-only values: a prerendered page reads at build, a dynamic page reads at run time

BIEN_MAY_CHU has no public prefix. / is prerendered, so it prints the build value — the control for the section above. /dong awaits connection() and prints the start value, and the build value is absent from its HTML. The Next.js docs give this exact await connection() sample under “Runtime Environment Variables”.

So one image can be built once and run in several environments with different server values, as long as the page reads at run time. A page that does not call connection() keeps the build value.

Dynamic lookups: a key passed in at run time is not replaced, a key held in a const is

This page measures four reads inside one Client Component. A literal process.env.NEXT_PUBLIC_TEN is replaced with its value in the JavaScript. A key passed in as a prop and read as process.env["NEXT_PUBLIC_" + khoa] is not: that key's value appears in no file under .next/static.

The other two forms both hold the key in a const declared in the same file, and the bundler folded both into a literal string. The assembled form ("NEXT_PUBLIC_" + hang) leaves that key's value in 1 file under .next/static. The fourth form is the docs' own example — the whole key name in a const, const varName = "NEXT_PUBLIC_VARNAME"; process.env[varName] — and its value sits in 1 file too. So the docs' sentence “dynamic lookups will not be inlined” does not hold for the docs' own example on this version.

The usable conclusion: do not rely on how you write a lookup to keep a public value out of the bundle. Treat every NEXT_PUBLIC_ variable as public. And a key passed in at run time is not safe by default either: that same line prints the build value in the prerendered HTML, because at build time it runs in Node, where the build's variables exist; what does not happen is the replacement inside the JavaScript file.

File order: `.env.local` beats `.env`, and `.env` needs no loader package

GIA_TRI_CHON is in both .env and .env.local. The page prints the .env.local value, and the .env value is absent from the HTML. The docs give the lookup order — process.env, then .env.$(NODE_ENV).local, .env.local, .env.$(NODE_ENV), and finally .env — and say .env.local is not loaded when NODE_ENV=test, which this page did not measure.

GIA_TRI_CHI_ENV exists only in .env and still reaches the page at build time, with no loader package installed. The sample app has no next.config handling variables and no outside .env package.

next.config.ts
import type { NextConfig } from "next";

// Nothing about env files here: the guide measures what Next.js does with them on its own.
const nextConfig: NextConfig = {};

export default nextConfig;

How this page was measured

One Next.js 16.4.4 app (React 19.3.0) is built once with a set of marker values, then started with next start and a different set. The script reads the HTML of / and /dong, and greps every text file under .next/static for each value. Every value is a made-up marker (gia-tri-…), never a real key. The result has 16 checks.

The versions are pinned at the top of the page. When Next.js ships a new version, we pin it and measure again; the page does not change by itself.

This page does not measure: next dev, variables set in next.config, a platform's own build variables, or an API built to serve a value at run time. It also does not measure NODE_ENV=test, where the docs say .env.local is not loaded.

Checked

  • next build exits 0: 0
  • the home page is prerendered at build time: prerendered
  • the /dong page reads the request, so it is not prerendered: dynamic
  • (a) a Client Component's HTML prints the build value, not the start value: gia-tri-a-7c1e
  • (a) the start value is nowhere in the home HTML: absent
  • (b control) a prerendered page reads the build value of a server-only variable: gia-tri-may-build-4e5a
  • (b) a page that awaits connection() reads the start value: gia-tri-may-start-8b3c
  • (b) the build value is nowhere in the /dong HTML: absent
  • (d) .env.local beats .env for the same key: the page prints the .env.local value: gia-tri-local-5c2d
  • (d) the .env value is nowhere in the home HTML: absent
  • (e) a value only in .env reaches the page with no loader package: gia-tri-chi-env-9b0e
  • measured: the prerendered HTML of the dynamic-key line holds the build value: present
  • (c) a key passed in at run time (a prop) is not inlined: its value is in 0 files under .next/static: 0 files
  • measured: a key held in a const in the same file is folded, its value is in .next/static: 1 file
  • measured: the docs' own example (the whole key in a const, no concatenation) is folded, its value is in .next/static: 1 file
  • (c control) a literal NEXT_PUBLIC_ read is inlined into >= 1 file under .next/static: 1 file

Measuring script (not public yet): bun scripts/guides/verify-env.ts

Related

Sources

Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.