Next.js 环境变量实战:NEXT_PUBLIC 前缀、构建时 vs 运行时与密钥泄露排查 Next.js 环境变量实战:NEXT_PUBLIC 前缀、构建时 vs 运行时与密钥泄露排查「我在.env里配了API_KEY,前端process.env.API_KEY却是 undefined」「本地好好的,部署到服务器变量全丢了」「更可怕的是,我的密钥居然出现在浏览器打包文件里」。Next.js 的环境变量踩坑率极高,因为它同时跨了服务端和客户端两个世界,规则和纯前端项目完全不同。这篇按真实排查顺序讲清楚。第一个坑:客户端读不到变量新建.env.local:API_KEYsk-secret-123NEXT_PUBLIC_SITE_NAME我的站点在一个客户端组件里读:use client; export default function Header() { // API_KEY 是 undefined,NEXT_PUBLIC_SITE_NAME 正常 console.log(process.env.API_KEY); // undefined console.log(process.env.NEXT_PUBLIC_SITE_NAME); // 我的站点 return h1{process.env.NEXT_PUBLIC_SITE_NAME}/h1; }这不是 bug,是 Next.js 的刻意设计:只有以NEXT_PUBLIC_开头的变量才会被打进客户端 bundle,其余变量只在服务端可见。原因很直接:客户端代码会下载到用户浏览器。如果所有变量都注入进去,你的数据库密码、第三方 API 密钥就全裸奔了。Next.js 用前缀强制你显式声明「这个变量我确认可以公开」。所以规则是:密钥类(数据库连接串、API secret、token):不加前缀,只在服务端组件 / Route Handler / Server Action 里用。公开配置(站点名、公开的分析 ID、公开 API 地址):加NEXT_PUBLIC_前缀,客户端才能读。第二个坑:NEXT_PUBLIC 是构建时「写死」的,不是运行时读的这个坑更隐蔽。很多人以为环境变量是程序运行时去读的,对NEXT_PUBLIC_变量来说不是——它们在next build那一刻就被字面替换进代码了。看编译前:const name process.env.NEXT_PUBLIC_SITE_NAME;next build之后,bundle 里实际是:constname我的站点;// 已经被替换成字面量字符串这带来一个致命后果:构建完成后再改环境变量,NEXT_PUBLIC_的值不会变。典型翻车场景:用 Docker 打了一个镜像,想在测试/生产环境用不同的NEXT_PUBLIC_API_URL——做不到,因为值在build时已经烤进镜像了。CI 里 build 时忘了设某个NEXT_PUBLIC_变量,结果它变成 undefined 烤进产物,线上怎么改服务器环境变量都没用。服务端变量则不同,它们是运行时读取的:// 服务端组件 / Route Handler,运行时读取,改了重启就生效 export async function GET() { const key process.env.API_KEY; // 运行时才求值 const res await fetch(https://api.example.com/data, { headers: { Authorization: Bearer ${key} }, }); return Response.json(await res.json()); }记忆口诀:NEXT_PUBLIC_ 构建时快照,服务端变量 运行时读取。要在多环境复用同一个镜像,公开配置就别用NEXT_PUBLIC_硬编,改用「运行时通过服务端接口下发配置」的方式(下面讲)。第三个坑:文件加载优先级和 .gitignoreNext.js 会按固定顺序加载多个 env 文件,后加载的不会覆盖已存在的同名变量(先到先得):.env.local # 最高优先级,本地专用,绝不提交 .env.development # next dev 时加载 .env.production # next build / next start 时加载 .env # 兜底默认值实战约定:.env:提交到仓库,放非敏感的默认值(如默认端口)。.env.local:写进.gitignore,放本地密钥,永远不提交。生产密钥:走部署平台(Vercel/K8s Secret/CI 变量),不落文件。确认.gitignore里有这行(Next.js 脚手架默认会加,但手搭项目常漏):# .gitignore.env*.local排查:密钥是不是泄露进了客户端?改完之后,一定要验证密钥没被打进前端。两个办法:方法一,build 后全局搜产物:next build# 在构建产物里搜你的密钥值,应该 0 命中grep-rsk-secret-123.next/static只要.next/static(客户端产物目录)里搜到密钥,就说明它被泄露了——大概率是你在客户端组件里读了非NEXT_PUBLIC_变量,或误加了前缀。方法二,浏览器 Network 面板看 JS chunk 内容,直接搜密钥字符串。一个常见的泄露写法是把服务端数据「透传」给客户端组件时连密钥一起传了:// 危险:整个 config 对象带着密钥传给了客户端组件 const config { apiKey: process.env.API_KEY, siteName: ... }; return ClientWidget config{config} /; // apiKey 会出现在 HTML 里!正确做法是只挑能公开的字段传:// 只传公开字段,密钥留在服务端 return ClientWidget siteName{process.env.NEXT_PUBLIC_SITE_NAME} /;进阶:运行时下发公开配置(解决多环境复用镜像)如果你确实要「一次构建、多环境部署」,又需要客户端拿到不同的公开配置,别用NEXT_PUBLIC_。改成客户端向自己的服务端接口请求配置,服务端运行时读环境变量返回:// app/api/config/route.ts —— 运行时读,改环境变量重启即生效 export async function GET() { return Response.json({ apiUrl: process.env.PUBLIC_API_URL, // 注意:没有 NEXT_PUBLIC_ 前缀 siteName: process.env.SITE_NAME, }); }use client; import { useEffect, useState } from react; export function useRuntimeConfig() { const [cfg, setCfg] useState{ apiUrl: string } | null(null); useEffect(() { // 客户端运行时拉取,值取决于当前环境的服务端变量,而非构建快照 fetch(/api/config).then(r r.json()).then(setCfg); }, []); return cfg; }这样同一个镜像丢到 test / prod,配置由各环境的运行时变量决定,不用为每个环境重新 build。代价是多一次请求 客户端初始没有配置的一小段空窗,按需取舍。小结NEXT_PUBLIC_前缀才会进客户端 bundle;没前缀的变量只在服务端可见,这是防密钥泄露的机制,别为了「读得到」乱加前缀。NEXT_PUBLIC_是构建时字面替换,build 后改不了;服务端变量是运行时读取,重启即生效。要多环境复用镜像,公开配置走运行时接口下发。文件优先级:.env.local.env.development/.env.production.env,先到先得;.env*.local必须进.gitignore。上线前排查:grep一下.next/static里有没有密钥,0 命中才安全。记忆点:Next.js 环境变量的一切困惑,都来自「这行代码到底跑在服务端还是客户端、值是在 build 时定的还是运行时读的」——先想清楚这两问,坑就绕开了。