English

Bảo mật

Biến môi trường trong Next.js: biến nào lộ ra trình duyệt

Tiền tố NEXT_PUBLIC_ là dòng ranh giới, nhưng ranh giới đó nằm ở lúc build chứ không ở lúc chạy. Trang này dựng một ứng dụng nhỏ trên Next.js 16.4.4, build với một bộ giá trị rồi khởi động với một bộ giá trị khác, để xem giá trị nào đi vào HTML và giá trị nào nằm trong tệp JavaScript gửi cho trình duyệt. Mọi con số dưới đây lấy từ một lần chạy script đo; mỗi phép đo có thể không đạt.

Mã trên trang này là một ứng dụng chạy được: 16/16 kiểm tra đạt ngày 14/11/2026. Phiên bản đã chạy thử: next@16.4.4, react@19.3.0, Node.js v26.0.0.

Hai loại biến, và hai trang đọc khác nhau

Ứng dụng mẫu có hai trang. / được render sẵn lúc build. /dong gọi await connection() trước khi đọc biến, nên nó đọc ở mỗi request.

Một biến có tiền tố NEXT_PUBLIC_ thì Next.js thay thẳng giá trị vào JavaScript gửi cho trình duyệt, ngay lúc next build. Một biến không có tiền tố đó chỉ có trên máy chủ. Trang server-client-components đã đo hai điều này, nên trang này không đo lại.

Thư mục mẫu có hai tệp .env và .env.local được commit, vì chúng chỉ chứa dấu tự đặt. Tài liệu Next.js cảnh báo đừng commit tệp .env thật.

Xem thêm: Server Component và Client Component: cái gì chạy ở đâu

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_` đóng băng ở lúc build

Lần chạy này build với một giá trị cho NEXT_PUBLIC_TEN, rồi khởi động next start với một giá trị khác. HTML của Client Component vẫn in giá trị lúc build; giá trị lúc khởi động không có ở đâu trong HTML.

Tài liệu Next.js nói cùng điều đó ở mục “Bundling Environment Variables for the Browser”: sau khi build, ứng dụng không còn phản ứng với thay đổi của những biến này, và nêu trường hợp một image Docker được đưa qua nhiều môi trường — mọi giá trị NEXT_PUBLIC_ đóng băng theo lúc build. Trang deploy đo việc dựng image; trang này chỉ đo giá trị mà trang trả về.

Muốn một giá trị đọc lúc chạy mà vẫn hiện ở trình duyệt, tài liệu đề nghị tự dựng một API trả về nó (theo yêu cầu, hoặc lúc khởi tạo). Trang này không đo cách đó.

Xem thêm: Deploy Next.js lên VPS và Docker: standalone, reverse proxy, Server Actions

Biến chỉ máy chủ: trang render sẵn đọc lúc build, trang động đọc lúc chạy

BIEN_MAY_CHU không có tiền tố công khai. Trang / render sẵn nên in giá trị lúc build — đó là phép đối chứng cho mục trên. Trang /dong gọi await connection() nên in giá trị lúc khởi động, và giá trị lúc build không có trong HTML của nó. Tài liệu Next.js đưa đúng mẫu await connection() này ở mục “Runtime Environment Variables”.

Vì vậy một image dựng một lần vẫn chạy được ở nhiều môi trường với giá trị máy chủ khác nhau, miễn trang đó đọc lúc chạy. Trang không gọi connection() thì giữ giá trị lúc build.

Tra cứu động: khóa truyền vào lúc chạy thì không bị thay, khóa giữ trong hằng thì bị thay

Trang này đo bốn dạng đọc trong cùng một Client Component. Đọc thẳng process.env.NEXT_PUBLIC_TEN thì giá trị được thay vào JavaScript. Khóa truyền vào dưới dạng prop rồi tra process.env["NEXT_PUBLIC_" + khoa] thì không: giá trị của khóa đó không có trong tệp nào dưới .next/static.

Hai dạng còn lại đều giữ khóa trong một hằng khai trong cùng tệp, và cả hai đều bị bundler gộp thành chuỗi cố định. Dạng ghép chuỗi ("NEXT_PUBLIC_" + hang) để lại giá trị trong 1 tệp dưới .next/static. Dạng thứ tư là đúng ví dụ của tài liệu — cả tên khóa nằm trong hằng, const varName = "NEXT_PUBLIC_VARNAME"; process.env[varName] — và giá trị của nó cũng có trong 1 tệp. Vì vậy câu “dynamic lookups will not be inlined” trong tài liệu không đúng với chính ví dụ của tài liệu trên phiên bản này.

Kết luận dùng được: đừng dựa vào cách viết phép tra cứu để giữ một giá trị công khai ra ngoài bundle. Hãy coi mọi biến có tiền tố NEXT_PUBLIC_ là công khai. Còn khóa truyền vào lúc chạy vẫn chưa chắc an toàn: dòng đó in giá trị lúc build trong HTML render sẵn, vì lúc build nó chạy trong Node, nơi có biến của lúc build; cái không xảy ra chỉ là việc thay giá trị vào tệp JavaScript.

Thứ tự tệp: `.env.local` thắng `.env`, và `.env` được đọc mà không cần gói nạp biến

GIA_TRI_CHON có trong cả .env và .env.local. Trang in giá trị trong .env.local, và giá trị trong .env không có trong HTML. Tài liệu xếp thứ tự tra: process.env, rồi .env.$(NODE_ENV).local, .env.local, .env.$(NODE_ENV), cuối cùng là .env — và .env.local không được nạp khi NODE_ENV=test, điều trang này không đo.

GIA_TRI_CHI_ENV chỉ có trong .env và vẫn đến được trang lúc build, không cần cài gói nạp biến nào. Ứng dụng mẫu không có next.config xử lý biến và không có gói .env bên ngoài.

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;

Cách đo trang này

Một ứng dụng Next.js 16.4.4 (React 19.3.0), build một lần với bộ giá trị đánh dấu, rồi next start với bộ giá trị khác. Script đọc HTML của / và /dong, và grep mọi tệp văn bản dưới .next/static để tìm từng giá trị. Mỗi giá trị là một dấu tự đặt (gia-tri-…), không phải khóa thật. Kết quả có 16 phép đo.

Phiên bản đã ghim ở đầu trang. Khi Next.js ra bản mới, chúng tôi ghim lại và đo lại; trang không tự đổi.

Trang này không đo: next dev, biến đặt trong next.config, biến của nền tảng triển khai, hay một API tự dựng để trả biến lúc chạy. Cũng không đo NODE_ENV=test, nơi tài liệu nói .env.local không được nạp.

Đã kiểm tra

  • 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

Script đo (chưa công khai): bun scripts/guides/verify-env.ts

Liên quan

Nguồn

Tóm tắt và diễn giải bằng lời của chúng tôi; mã viết lại từ ví dụ trong tài liệu Next.js. Khi có khác biệt, tài liệu Next.js là chuẩn.