Next.js 环境变量实战:NEXT_PUBLIC 前缀、构建时 vs 运行时与密钥泄露排查
「我在.env里配了API_KEY,前端process.env.API_KEY却是 undefined」「本地好好的,部署到服务器变量全丢了」「更可怕的是,我的密钥居然出现在浏览器打包文件里」。Next.js 的环境变量踩坑率极高,因为它同时跨了服务端和客户端两个世界,规则和纯前端项目完全不同。这篇按真实排查顺序讲清楚。
第一个坑:客户端读不到变量
新建.env.local:
API_KEY=sk-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_硬编,改用「运行时通过服务端接口下发配置」的方式(下面讲)。
第三个坑:文件加载优先级和 .gitignore
Next.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-r"sk-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 时定的还是运行时读的」——先想清楚这两问,坑就绕开了。