说到环境变量很多人第一反应可能是配置 JDK 或 Python 的 PATH也可能是 Linux 服务器上那串 export 命令。不同生态做法五花八门核心诉求却完全一致把跟环境有关的配置从代码里拆出来让同一个项目在不同环境里使用不同的值。Next.js 的环境变量机制就是围绕这个目标设计的但它比普通后端项目多了一层麻烦——代码同时跑在服务端和浏览器两端环境变量的可见范围必须分清楚。这篇内容我会完整拆解 Next.js 环境变量的加载规则、NEXT_PUBLIC_ 前缀的底层逻辑、服务端与客户端的安全边界以及我在实际项目里排查过的典型问题。无论你是刚接触 Next.js 的新手还是已经写了几个项目但被 .env 文件搞晕过的人都能从中找到可以直接落地的方案。1. 环境变量在 Next.js 里的角色与加载顺序1.1 Next.js 如何读取环境变量很多从其他框架转过来的同学第一件事就是去装dotenv然后在入口文件里手动require(dotenv).config()。在 Next.js 里这件事完全不需要做框架内置了环境变量加载能力。当你执行next dev、next build或next start时Next.js 会自动读取项目根目录下的.env系列文件并把里面的键值对注入到process.env中。注意这里说的是项目根目录不是src目录也不是app目录。文件放错位置框架根本找不到。这个内置加载机制的底层是next/env这个包它本质上封装了 dotenv 的逻辑并加入了 Next.js 自己的文件优先级和环境模式判断。所以你不必手动引入 dotenv也不需要自己实现按环境加载文件的逻辑。我见过有人在next.config.js里自己写了一段读取.env文件的代码结果反而跟框架内置逻辑冲突导致某些变量时有时无。建议新项目一律使用框架机制少写自定义逻辑。1.2 .env 系列文件的分工与优先级Next.js 支持以下环境变量文件.env、.env.local、.env.development、.env.production、.env.test。它们的含义和加载优先级可以从下表看出来。文件名加载条件建议是否提交到仓库.env所有环境默认加载可以提交放公共且非敏感的配置.env.local本地环境覆盖优先级较高不要提交默认被 gitignore.env.development仅当 NODE_ENV 为 development 时加载可以提交团队共享开发配置.env.production仅当 NODE_ENV 为 production 时加载可以提交团队共享生产配置.env.test仅当 NODE_ENV 为 test 时加载可以提交保证测试环境一致这里有一个容易记混的细节优先级从高到低大致是“真实环境变量 →.env.local→.env.development或.env.production→.env”。也就是说如果同一个变量在多个文件里出现取高优文件的值。这种设计的意图很明确.env提供公共默认值环境文件提供按环境覆盖.env.local又允许每个开发者在本地做个性化覆盖而不影响团队其他人。特别注意.env.local在测试环境下不会被加载。这是 Next.js 为了方便自动化测试而定的规矩因为测试需要可复现、可预测的环境不能因为某台机器上残留一个.env.local导致测试结果漂移。如果你在跑测试时发现某些变量加载不到先确认一下是不是把配置写进了.env.local。1.3 环境变量值的书写格式与常见反模式.env 文件的格式大部分人已经见过但一些隐藏的小坑还是值得专门说。基本格式就是KEYVALUE每行一个变量#开头表示注释。value 里面如果包含空格、#、这类特殊字符建议用双引号把整个值包起来。比如密码是abc#123如果你直接写PASSWORDabc#123dotenv 解析时会把#123当作注释的一部分最终拿到的 value 只是abc。这种情况在数据库连接串里尤其常见连接串往往包含#或等字符。另一个常见反模式是在.env文件里写export DATABASE_URLxxx。dotenv 本身支持不带 export 的写法但如果你从别的环境复制命令到文件里多写export在某些解析器下会直接报错。此外等号两边不要加空格KEY VALUE这种写法容易被解析成奇怪的键值排查起来非常难受。最后再提醒一点NODE_ENV不能通过在.env里设置来改变它是 Next.js 内部控制的由运行命令决定。你在.env里写NODE_ENVproduction不会生效别在这上面浪费时间。2. 服务端与客户端的边界NEXT_PUBLIC_ 前缀2.1 为什么会有 NEXT_PUBLIC_ 前缀这是 Next.js 环境变量体系里最核心、也最容易踩坑的设计。普通后端项目只有一个运行环境所有环境变量默认都在服务端浏览器端根本接触不到。但 Next.js 是前后端一体的框架同一个代码仓库里既有服务端逻辑也有发到浏览器执行的客户端代码。问题来了浏览器端的代码里如果写了process.env.XXX这个变量到哪去取答案就是NEXT_PUBLIC_前缀。带这个前缀的变量Next.js 在构建阶段会把代码里出现的process.env.NEXT_PUBLIC_XXX直接替换成实际值然后打包进浏览器产物。也就是说它本质上是一种“编译期替换”而不是运行时读取。不带前缀的变量默认只在服务端可用浏览器端拿到的永远是undefined。如果你之前写过客户端组件去读process.env.DATABASE_URL然后得到 undefined那就是撞上了这个边界。2.2 构建时内联的工作原理理解 NEXT_PUBLIC_ 的底层原理很多疑惑会迎刃而解。它靠的是 webpack 的 DefinePlugin 思想在打包阶段把变量引用替换成字符串字面量。举个例子你在代码里写const apiBase process.env.NEXT_PUBLIC_API_BASE_URL;构建时如果.env.production里定义了这个变量打包工具会把上面这行代码直接替换成const apiBase https://api.example.com;整个过程发生在next build阶段跟运行时无关。这也是为什么你修改了带上 NEXT_PUBLIC_ 前缀的变量之后只重启next start没用必须重新执行next build。因为浏览器收到的已经是替换后的静态代码不是现场去读环境变量。这个道理用一句话概括NEXT_PUBLIC_ 变量是写给构建时的不是写给运行时的。2.3 哪些变量可以放 NEXT_PUBLIC_既然前缀变量会被打进前端包那就意味着任何人通过浏览器开发者工具都能看到它的值。所以这里有一条非常明确的安全红线不要把密钥、密码、Token 等敏感信息放进 NEXT_PUBLIC_ 变量。很多安全事故就是这样发生的某个人误把NEXT_PUBLIC_SUPABASE_SERVICE_KEY或NEXT_PUBLIC_STRIPE_SECRET写进环境变量密钥直接被曝光。适合使用 NEXT_PUBLIC_ 前缀的典型场景有前端需要访问的 API 基础地址、站点对外域名、第三方 SDK 的公开 Key比如地图服务的 public key、灰度开关标志位。这些信息就算公开也不会造成直接损失。凡是需要保密的数据一律只放在服务端变量里由服务端读取后按需使用。2.4 在不同运行环境中的使用位置服务端组件、路由处理程序和 Server Actions 属于 Node.js 服务端运行环境可以直接读取不带前缀的环境变量。因为在服务端代码里process.env就是真实的进程环境密钥、数据库连接串都在这里读取。这一点完全符合直觉。客户端组件属于浏览器运行环境只能读取 NEXT_PUBLIC_ 前缀变量。如果你在一个use client文件里直接读不带前缀的变量拿到的一定是 undefined。这不是配置错了而是框架刻意做的隔离保护。中间件middleware.ts运行在 Edge Runtime情况比较特殊它介于服务端和构建产物之间。自托管部署时middleware 会被打包成边缘函数构建时读取的变量才能可靠地出现在里面运行时才注入的宿主机环境变量不一定能访问到。所以我在实际项目中的习惯是middleware 里只读取 NEXT_PUBLIC_ 变量或确定构建时就存在的配置不依赖运行时动态注入的敏感项。2.5 安全地把服务端数据传递给客户端你可能会遇到这样的需求页面要展示一些来自数据库或第三方服务的配置信息这些信息需要用密钥去服务端获取但展示时又必须在浏览器端渲染。正确的做法是通过服务端组件读取环境变量和处理数据然后把处理后的结果通过 props 传给客户端组件而不是把原始密钥通过 props 传下去。// app/dashboard/page.tsx import { fetchStats } from lib/stats; export default async function DashboardPage() { // 密钥只在这里读 const stats await fetchStats(); return StatsChart data{stats} /; }use client; export function StatsChart({ data }: { data: Recordstring, number }) { return div{JSON.stringify(data)}/div; }这种方式既保证了密钥不会进前端包又能在浏览器端展示服务端数据。如果你想彻底防止客户端代码误引入服务端模块可以在lib/stats.ts顶部加上import server-only这样一旦某个客户端组件试图引入这个模块构建会直接报错。这个技巧我几乎在每个项目里都会用能提前暴露很多因文件引用混乱导致的安全问题。3. 实操配置一个带环境变量的 Next.js 项目3.1 创建项目与文件结构先创建一个全新的 Next.js 项目这里我直接使用当前稳定的 App Router 结构。npx create-next-applatest env-demo项目创建好之后在根目录创建三个文件.env.example、.env.local、.env.production。.env.example的作用是给团队一个配置清单里面不填真实值只写变量名和注释提交到仓库里方便新成员快速了解需要配置哪些项。.env.local放你自己的本地配置默认被 gitignore不进仓库。.env.production放生产环境的公共配置如果变量本身不敏感可以提交给团队共用。# .env.example SITE_NAMEEnv Demo DATABASE_URLpostgresql://user:passlocalhost:5432/demo NEXT_PUBLIC_API_BASE_URLhttps://api.example.com NEXT_PUBLIC_SHOW_BANNERtrue在.env.local里复制一份并填入本机真实值比如把 DATABASE_URL 指向本地数据库NEXT_PUBLIC_API_BASE_URL 指向你本地运行的接口服务。这样本地开发和线上生产走的是完全不同的配置路径但代码完全一致。3.2 在服务端组件与客户端组件中使用接下来在页面里分别演示服务端和客户端读取环境变量的方式。服务端组件可以直接读取任何变量因为它运行在 Node.js 进程里。// app/page.tsx export default async function Home() { const databaseConfigured Boolean(process.env.DATABASE_URL); return ( main p数据库连接{databaseConfigured ? 已配置 : 未配置}/p p站点名称{process.env.SITE_NAME ?? 未配置}/p /main ); }客户端组件就不要轻易读取非前缀变量了这里用一个功能开关演示 NEXT_PUBLIC_ 的典型用法。假设.env.local里设置了NEXT_PUBLIC_SHOW_BANNERtrue那么只有值为字符串true时页面才展示公告条。use client; export default function Banner() { const showBanner process.env.NEXT_PUBLIC_SHOW_BANNER true; if (!showBanner) return null; return div classNamebanner系统公告当前为测试环境/div; }注意这里的代码不依赖任何运行时接口构建时就已经决定了 showBanner 的值。如果你想精细控制每个变量在哪个端可用最好在代码里留下注释说明该变量的用途和公开性防止后来的人误改。3.3 TypeScript 类型增强与运行时校验默认情况下process.env.XXX的类型是string | undefined用起来没问题但团队协作时很容易因为变量名拼错而踩坑。我习惯在项目里加一个类型声明文件让编辑器自动补全环境变量名。// src/types/env.d.ts declare namespace NodeJS { interface ProcessEnv { DATABASE_URL: string; NEXT_PUBLIC_API_BASE_URL: string; NEXT_PUBLIC_SHOW_BANNER?: string; SITE_NAME?: string; } }这样写之后在服务端代码里访问process.env.DATABASE_URL就有类型提示了。还有一个更严格的做法是在启动阶段用 zod 对环境变量做运行时校验只要缺失或格式不对就直接抛出异常让问题在启动瞬间暴露而不是等到某个功能真正用到变量时才报错。这个做法适合团队规模较大、环境变量数量较多的项目。import { z } from zod; const envSchema z.object({ DATABASE_URL: z.string().min(1), NEXT_PUBLIC_API_BASE_URL: z.string().url(), }); export const env envSchema.parse(process.env);这里的取舍很简单类型声明只解决编码体验zod 校验解决运行时可靠性。我个人的推荐是至少加上类型声明如果你的项目涉及支付、权限等关键链路再上 zod 校验。3.4 用调试接口快速确认变量是否加载排查环境变量问题时与其反复猜测不如写一个临时接口把加载结果一五一十地暴露出来。下面这个 API 路由会返回几个关键变量的加载状态注意千万不要把密钥值直接返回给调用方只返回是否存在。// app/api/env/route.ts import { NextResponse } from next/server; export async function GET() { return NextResponse.json({ nodeEnv: process.env.NODE_ENV, hasDatabaseUrl: Boolean(process.env.DATABASE_URL), apiBase: process.env.NEXT_PUBLIC_API_BASE_URL ?? null, showBanner: process.env.NEXT_PUBLIC_SHOW_BANNER ?? null, }); }浏览器或终端里访问http://localhost:3000/api/env就能看到当前进程实际加载了哪些变量。运行接口的是服务端进程所以这里能读到 DATABASE_URL 这样的敏感变量是否存在但不会暴露具体内容。如果你确认.env.local里明明写了某个变量接口却返回 false那就可以从文件位置、拼写、加载顺序这几个方向去查。这个接口在生产环境记得删掉或加上鉴权避免信息泄露。4. 运行时环境变量Docker、SSR 与边缘运行时4.1 构建时替换与运行时读取的本质区别很多人会混淆“构建时”和“运行时”这两个时间点。Next.js 服务端代码里的process.env在两种情况下表现不一样。动态渲染的服务端组件、API 路由和 Route Handler 在运行时执行访问的是宿主机进程里真实的环境变量所以只要你重启服务并注入新值就能动态生效不需要重新构建前端。但是带有 NEXT_PUBLIC_ 前缀的变量在构建时已经被替换成字符串了运行时再改宿主机环境变量对已经构建好的产物没有影响。静态生成SSG页面还要额外注意如果页面在构建时预渲染即使是非前缀变量也会被内联到生成的 HTML 里。此时修改运行时的环境变量并不会更新这些静态页面你必须重新触发构建或改为动态渲染才能拿到新值。这个细节在自托管部署中很常见一不小心就会造成线上页面配置过期。4.2 Docker 部署时的环境变量注入Docker 部署是自托管最常见的场景。很多人在容器里遇到的现象是明明docker run -e DATABASE_URLxxx传了变量服务端能读到但浏览器端拿不到。原因仍然是 NEXT_PUBLIC_ 前缀变量在构建时就已经固化。如果你在构建镜像时没有传入 NEXT_PUBLIC_ 变量构建产物里的对应值就是 undefined运行时docker run -e NEXT_PUBLIC_XXXyyy也不会补进去。正确做法是把next build和next start分开考虑。构建镜像时把你希望出现在浏览器端的公开变量作为构建参数传入比如 Dockerfile 里的 ARG 和 ENV运行时通过docker run -e传入的则是服务端需要动态读取的敏感配置。docker run -d \ -e DATABASE_URLpostgresql://user:passhost:5432/db \ -e JWT_SECRETyour-secret \ -p 3000:3000 my-next-app上面的命令适合服务端敏感变量。如果你还想在容器启动时动态改变前端展示的某些公开配置可以考虑采用 4.3 里的服务端传参方式用接口或服务端组件把配置下发到前端而不是强行依赖 NEXT_PUBLIC_ 变量。4.3 通过服务端向客户端传递动态配置如果业务上确实需要前端展示一些运行时才能确定的配置比如 A/B 实验的开关、营销活动展示位最稳妥的方案是把服务端读取到的环境变量通过 props 传递到客户端组件。// app/promotion/page.tsx export default async function PromotionPage() { const bannerText process.env.PROMOTION_BANNER_TEXT ?? default; return PromotionBanner text{bannerText} /; }这样做的优势很明显你不需要在构建时决定这些配置只要修改宿主机环境变量并滚动重启服务新配置就能通过服务端组件渲染到页面上。它虽然多了一点代码但比维护一套复杂的运行时配置框架简单得多。我的建议是所有需要频繁变化的非敏感配置都走这条路径NEXT_PUBLIC_ 只用来承载真正稳定且公开的基础配置。4.4 边缘运行时的环境变量限制Next.js 的 middleware 运行在 Edge Runtime它的打包和执行模型更接近浏览器而不是 Node.js。在这个环境里环境变量的来源主要是构建时能拿到的值以及平台在边缘节点注入的值。自托管部署时如果你依赖一个只有在next start启动后才 export 的变量middleware 很可能读不到。我踩过的一个真实坑是把权限校验逻辑放在 middleware 里需要读取一个只能从部署平台动态获取的 Token结果生产环境里 middleware 一直拿不到值导致所有请求都被拦截。后来把 Token 读取挪到了 API 路由里处理middleware 只做轻量级判断问题就解决了。在边缘运行时里处理环境变量原则是能少读就少读不要塞入过多运行时依赖。如果你确实需要复杂决策优先放在 Node.js 服务端那里环境变量行为最容易预测。5. 常见问题与排查技巧实录5.1 修改 .env 后变量不生效这个问题我在社区里看到过无数次原因通常有三个。第一进程缓存问题修改环境变量后没有重启next dev或next start进程内存里还是旧值。第二修改的是 NEXT_PUBLIC_ 变量但只重启服务没有重新构建浏览器拿到的是旧构建产物。第三改错了文件开发环境要看.env.development生产环境要看.env.production结果你把值写进了.env.local而目标环境根本不读这个文件。如果你已经排除了这三类原因可以尝试清掉构建缓存再启动。Next.js 的缓存目录是.next删除后重新构建通常能解决很多莫名其妙的变量不生效问题。rm -rf .next npm run dev5.2 process.env.XXX 为 undefined遇到 undefined先别急着怀疑框架出 bug。按顺序检查变量名拼写环境变量区分大小写DATABASE_URL和database_url是两个完全不同的变量。再检查文件位置环境变量文件必须在项目根目录否则不会被加载。接着检查访问位置如果是在客户端组件里访问非前缀变量那么永远都是 undefined这是设计使然。最后检查加载优先级是否被另一个文件里的同名空值覆盖了。我曾经遇到过.env.local里写了API_KEY空值导致.env里的真实配置被覆盖成 undefined 的情况。用 3.4 里的调试接口可以快速验证。5.3 前端产物中出现敏感信息当你在浏览器开发者工具或 GitHub 仓库里看到不该出现的密钥基本可以断定是下面三种原因之一。变量名错误地加了 NEXT_PUBLIC_ 前缀服务端把原始密钥通过 props 传给了客户端组件代码里直接硬编码了密钥字符串。前两种属于使用不当第三种属于程序员懒省事。验证方法很简单构建完成后搜索一下静态产物npm run build grep -R SUPABASE_SERVICE_KEY .next/static || echo 静态产物未发现敏感变量如果搜到了就需要立刻修正环境变量名和引用方式然后重新构建并部署。养成在发布前跑一遍搜索的习惯能帮你挡住大多数误泄事件。5.4 特殊字符与解析问题.env 文件里最常见的解析事故来自#和空格。数据库连接串里经常出现#作为密码或参数的一部分如果不用引号包起来解析会被截断。正确的写法如下DATABASE_URLpostgresql://user:pass#wordlocalhost:5432/db另一种情况是环境变量值需要以空格结尾或者值本身就是空字符串。空字符串KEY会被解析成空值而不是 undefined这能用来有意覆盖默认配置。还有人在 Windows 上编辑 .env 文件导致 BOM 头混入键名也会让变量无法匹配建议统一使用 UTF-8 无 BOM 编码。5.5 next.config.js 里读取环境变量next.config.js 默认运行在 Node.js 环境下而且 Next.js 在加载这个文件之前会先加载 .env 系列文件所以在里面直接读取process.env是可行的。很多团队会在 next.config.js 里根据环境变量做构建配置比如按环境切换图片域名白名单。const nextConfig { images: { domains: [process.env.NEXT_PUBLIC_IMAGE_DOMAIN || localhost], }, }; export default nextConfig;这里有一个历史遗留的坑next.config.js 里的env字段可以把变量暴露给浏览器端但它本身也是一种构建时替换而且默认暴露范围是整个客户端 bundle行为跟 NEXT_PUBLIC_ 几乎一样但不够显式。新项目建议不要再用这个字段统一用 NEXT_PUBLIC_ 前缀暴露公开变量代码可读性会好很多。5.6 跨生态排查思路的通用性聊了这么多 Next.js 环境变量的问题其实它的排查思路跟其他生态是相通的。搜索热词里那些关于 JDK、Python、conda、Maven 环境变量配置失败的求助核心原因翻来覆去无非就是三类配置文件没生效、作用域不对、拼写或路径错误。Next.js 只不过把“作用域”从操作系统进程粒度细化到了服务端与浏览器端粒度。你如果已经养成了“先确认加载源、再确认作用范围、最后验证运行环境”的排查习惯无论切到哪个技术栈都不会慌。写在最后的一点经验最后分享一个我自己的习惯算是这些年踩坑换来的教训。我手里每个 Next.js 项目都会维护一份严格的.env.example每个变量都写明用途、是否公开、对应运行环境.env.local永远不进仓库部署平台的环境变量面板是敏感配置的唯一下发渠道任何需要给客户端使用的变量必须显式带 NEXT_PUBLIC_ 前缀并在代码注释里标注这是公开值每次发版前用 grep 检查静态产物里有没有出现预期外的敏感关键词。这套流程看起来繁琐但确实帮我挡掉了不少线上事故。环境变量从来不是高深的技术真正的杀伤力都藏在“我以为它不会出现在那里”的疏忽里养成随手确认变量的习惯比背十篇教程都管用。