1. 概述
环境变量本质上是一组注入到运行时环境里的配置值,用来告诉应用当前该连接什么环境、运行在哪、打开什么能力。它和写死在代码里的常量不同,环境变量的配置可以随着开发、联调、生产环境切换。
这个项目里,环境变量最直接承担两类职责:
- 标记当前业务运行语义,比如
APP_ENV - 提供外部依赖地址,比如
API_BASE_URL
当前 web 端直接读取 process.env.API_BASE_URL,并带了一个本地回退值。api 侧的 wrangler.jsonc 还没有真正的多环境变量配置,admin 端也还没接入任何环境变量。
所以目标很明确:
- 三端统一环境变量命名
- 同时覆盖 Next.js 服务端和客户端组件
- 显式区分
development、test、production三种环境 - 去掉页面代码里的硬编码回退地址
- 让 API、Web、Admin 都真正消费同一套环境约定
- 顺带把
zod收敛到工作区根级catalog管理
2. Next.js 如何读取环境变量
先把 Next.js 自己的规则讲清楚,不然后面项目里的配置策略很容易写偏。
如果你对 next 还不熟悉,可以关注我的另外一本付费小册《NextJS 实战进阶》
2.1 Next.js 会按什么顺序加载 env 文件
Next.js 内置了对环境变量的支持,会把 .env* 文件加载到 process.env。
常见文件有这些:
.env.env.development.env.production.env.test.env.local.env.development.local.env.production.local.env.test.local
官方的查找顺序可以概括成这样:
- 先看当前进程里已经存在的
process.env - 再看
.env.$(NODE_ENV).local - 再看
.env.local,但test环境会跳过它 - 再看
.env.$(NODE_ENV) - 最后看
.env
这里最容易搞错两点。
第一,.env.local 不是永远参与。
当 NODE_ENV=test 时,Next.js 会跳过 .env.local,这样测试结果更稳定,不会被某台机器上的本地私有配置污染。
第二,NODE_ENV 和业务环境不是一回事。
next dev 对应的是 development,next build 和 next start 对应的是 production。只有测试进程自己把 NODE_ENV 设成 test 时,Next.js 才会自动走测试环境那套加载规则。
这也是为什么当前文章里的「联调环境=test」不能直接等同于 Next.js 官方语义里的 test。
2.2 服务端为什么可以直接读取 process.env
Server Component、Route Handler、Server Action 都运行在服务端,这一层直接读取的是 Node.js 进程环境。
也就是说,process.env 不是 Next.js 发明出来的能力,它本来就是 Node.js 提供的进程环境入口。Next.js 做的事情,是在应用启动时,先按规则把 .env* 文件加载进去,再去执行服务端代码。
完整链路可以理解成这样:
- 先在
.env*文件、系统环境变量或部署平台里配置值 - Next.js 启动时按优先级把它们加载到当前进程
- Node.js 通过
process.env暴露这些值 - Server Component / Route Handler / Server Action 在运行时直接读取
例如服务端 helper 可以这样写:
01import { z } from 'zod'0203const webServerEnvSchema = z.object({04APP_ENV: z.enum(['development', 'test', 'production']),05API_BASE_URL: z.string().url(),06})0708export type WebServerEnv = z.infer<typeof webServerEnvSchema>0910export function getWebServerEnv(): WebServerEnv {11return webServerEnvSchema.parse({12APP_ENV: process.env.APP_ENV,13API_BASE_URL: process.env.API_BASE_URL,14})15}
这里还有一个官方文档专门提过的点:读取时直接写 process.env.APP_ENV 这种属性访问,不要先解构 process.env 再用。
本地开发时,web 和 admin 最常见的配置文件会是这些:
apps/web/.env.developmentapps/web/.env.testapps/admin/.env.developmentapps/admin/.env.test
例如 web 侧本地开发:
1APP_ENV=development2API_BASE_URL=http://127.0.0.1:87883NEXT_PUBLIC_APP_ENV=development4NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8788
2.3 客户端为什么只能用 NEXT_PUBLIC_*
客户端组件跑在浏览器里,浏览器拿不到 Node.js 的完整进程环境。为了避免把私有变量直接暴露出去,Next.js 只会把带 NEXT_PUBLIC_ 前缀的环境变量内联到发送给浏览器的 JavaScript 里。
这意味着两件事。
第一,客户端只能访问 NEXT_PUBLIC_*。
像 API_BASE_URL、APP_ENV 这种没有公共前缀的变量,客户端组件里不应该直接依赖。
第二,NEXT_PUBLIC_* 是在前端代码编译时写进去的。
也就是说,客户端看到的是 next dev 或 next build 当时确定下来的值,不是浏览器运行时再去现查操作系统环境变量。
客户端 helper 可以这样写:
01import { z } from 'zod'0203const webClientEnvSchema = z.object({04NEXT_PUBLIC_APP_ENV: z.enum(['development', 'test', 'production']),05NEXT_PUBLIC_API_BASE_URL: z.string().url(),06})0708export type WebClientEnv = z.infer<typeof webClientEnvSchema>0910export function getWebClientEnv(): WebClientEnv {11return webClientEnvSchema.parse({12NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,13NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,14})15}
使用时要记住一条边界:不要把 env.server.ts 引进 "use client" 组件。
例如 web 首页本身还是 Server Component,所以它继续走服务端 helper:
01import { getWebServerEnv } from '../src/env.server'02import { WebEnvBadge } from '../src/web-env-badge'0304async function getPingResponse(apiBaseUrl: string): Promise<PingRpcResponse> {05const client = hc<AppType>(apiBaseUrl)06const response = await client.rpc.system.ping.$post({07json: rpcPayload,08})0910return await response.json()11}1213export default async function Home() {14const env = getWebServerEnv()15const pingResult = await getPingResponse(env.API_BASE_URL)1617return (18<section>19<span>server {env.APP_ENV}</span>20<span>{env.API_BASE_URL}</span>21<WebEnvBadge />22</section>23)24}
而 WebEnvBadge 是客户端组件,它读的是公开变量:
01"use client"0203import { getWebClientEnv } from './env.client'0405export function WebEnvBadge() {06const env = getWebClientEnv()0708return (09<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">10<span className="rounded-full border border-border px-3 py-1">11client {env.NEXT_PUBLIC_APP_ENV}12</span>13<span className="rounded-full border border-border px-3 py-1">14{env.NEXT_PUBLIC_API_BASE_URL}15</span>16</div>17)18}
3. Hono API 怎么读取环境变量
Hono 本身只是路由框架,真正的环境变量入口取决于它跑在哪。
这个项目里的 API 跑在 Cloudflare Worker,所以读取入口不是 process.env,而是 c.env。
先看配置入口。wrangler.jsonc 负责定义默认环境和具名环境:
01{02"$schema": "node_modules/wrangler/config-schema.json",03"name": "api",04"main": "src/index.ts",05"compatibility_date": "2026-04-22",06"vars": {07"APP_ENV": "development"08},09"env": {10"test": {11"vars": {12"APP_ENV": "test"13}14},15"production": {16"vars": {17"APP_ENV": "production"18}19}20}21}
这里的含义是:
- 顶层
vars给默认开发环境 env.test给联调环境env.production给生产环境
本地开发还要配 apps/api/.dev.vars,Wrangler 启动时会把它注入 Worker 运行时。
1APP_ENV=development
配置完以后,路由里不要直接散着读 c.env.APP_ENV,最好先集中校验一次:
01import { z } from 'zod'0203const apiEnvSchema = z.object({04APP_ENV: z.enum(['development', 'test', 'production']),05})0607export type ApiEnv = z.infer<typeof apiEnvSchema>0809export function getApiEnv(bindings: Record<string, unknown>): ApiEnv {10return apiEnvSchema.parse({11APP_ENV: bindings.APP_ENV,12})13}
然后在 Hono 路由里通过 c.env 读取:
01import { getApiEnv } from './env'0203const app = new Hono<{04Bindings: {05APP_ENV: 'development' | 'test' | 'production'06}07}>()0809const routes = app10.get('/health', (c) => {11const env = getApiEnv(c.env)1213return c.json(14buildSuccess(15{16service: 'api',17env: env.APP_ENV,18},19createMeta(),20),21)22})23.post('/rpc/system/ping', validator('json', (value, c) => {24const parsed = PingRequestSchema.safeParse(value)2526if (!parsed.success) {27return c.json(28buildFailure(29{30code: BizCode.COMMON_INVALID_REQUEST,31message: 'Invalid request payload',32details: parsed.error.flatten(),33},34createMeta(),35),36400,37)38}3940return parsed.data41}),42(c) => {43const payload = c.req.valid('json')44const env = getApiEnv(c.env)4546return c.json(47buildSuccess(48{49service: 'api',50message: `pong, ${payload.name}`,51env: env.APP_ENV,52},53createMeta(),54),55)56},57)
Hono 这一层的结论很明确:
- Worker 环境变量来自
wrangler.jsonc、.dev.vars和 secret - 运行时通过
c.env读取 - 最好先经过一个
env.ts做校验
4. 回到这个项目,环境变量如何设计
把 Next.js 和 Hono 各自的读取方式讲清之后,再看这个项目的方案就顺了。
当前三端统一使用四项环境变量:
APP_ENV=development | test | productionAPI_BASE_URLNEXT_PUBLIC_APP_ENVNEXT_PUBLIC_API_BASE_URL
它们的职责分成两层:
APP_ENV/API_BASE_URL给服务端逻辑用NEXT_PUBLIC_APP_ENV/NEXT_PUBLIC_API_BASE_URL给客户端组件用
这样设计有两个直接好处。
第一,Next.js 服务端和客户端边界很清楚,不会把私有变量误带进浏览器。
第二,web、admin、api 三端虽然运行时不同,但命名风格统一,后面排查问题时不容易乱。
这里不新建共享 env package,原因也很直接:
api跑在 Worker,读的是c.envweb和admin跑在 Next 服务端和浏览器,读的是process.env
运行时入口本来就不同,统一键名比统一读取代码更重要。
admin 侧也按同样方式拆成服务端和客户端两层:
01import { AdminEnvBadge } from '../src/admin-env-badge'02import { getAdminServerEnv } from '../src/env.server'0304export default function Home() {05const env = getAdminServerEnv()0607return (08<Card>09<CardHeader>10<CardTitle>Environment overview</CardTitle>11<CardDescription>12The admin app reads private server variables and public browser variables separately.13</CardDescription>14</CardHeader>15<CardContent className="space-y-4">16<div className="grid gap-3 md:grid-cols-2">17<div>18<p>APP_ENV</p>19<p>{env.APP_ENV}</p>20</div>21<div>22<p>API_BASE_URL</p>23<p>{env.API_BASE_URL}</p>24</div>25</div>26<AdminEnvBadge />27</CardContent>28</Card>29)30}
5. Turbo、示例文件修改
只改页面和脚本还不够,任务调度层和示例文件层也得同步,否则后面会继续冒出隐性问题。
第一件事,Turbo 要声明环境变量。
turbo.json 里的 build、build:test、start:test、dev、dev:test、lint、check-types 都应该补上四项变量:
APP_ENVAPI_BASE_URLNEXT_PUBLIC_APP_ENVNEXT_PUBLIC_API_BASE_URL
这样可以同时解决两个问题:
turbo/no-undeclared-env-vars告警- Turbo 缓存没有感知环境变量变化
01{02"tasks": {03"build": {04"dependsOn": ["^build"],05"inputs": ["$TURBO_DEFAULT$", ".env*"],06"outputs": [".next/**", "!.next/cache/**"],07"env": ["APP_ENV", "API_BASE_URL", "NEXT_PUBLIC_APP_ENV", "NEXT_PUBLIC_API_BASE_URL"]08},09"lint": {10"dependsOn": ["^lint"],11"env": ["APP_ENV", "API_BASE_URL", "NEXT_PUBLIC_APP_ENV", "NEXT_PUBLIC_API_BASE_URL"]12},13"check-types": {14"dependsOn": ["^check-types"],15"env": ["APP_ENV", "API_BASE_URL", "NEXT_PUBLIC_APP_ENV", "NEXT_PUBLIC_API_BASE_URL"]16},17"dev": {18"cache": false,19"persistent": true,20"env": ["APP_ENV", "API_BASE_URL", "NEXT_PUBLIC_APP_ENV", "NEXT_PUBLIC_API_BASE_URL"]21}22}23}
第二件事,示例文件和真实文件分层。
由于环境变量有可能涉及敏感信息,因此 git 仓库里只提交示例文件,不提交真实环境文件:
apps/api/.dev.vars.exampleapps/web/.env.exampleapps/admin/.env.example
前端示例文件现在要同时覆盖服务端和客户端变量:
01APP_ENV=development02API_BASE_URL=http://127.0.0.1:878703NEXT_PUBLIC_APP_ENV=development04NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:87870506# For pre-release / integration testing07# APP_ENV=test08# API_BASE_URL=https://test-api.example.com09# NEXT_PUBLIC_APP_ENV=test10# NEXT_PUBLIC_API_BASE_URL=https://test-api.example.com1112# For production13# APP_ENV=production14# API_BASE_URL=https://api.example.com15# NEXT_PUBLIC_APP_ENV=production16# NEXT_PUBLIC_API_BASE_URL=https://api.example.com
真实的环境变量配置文件需要依据示例文件创建,例如:
1APP_ENV=development
1APP_ENV=development2API_BASE_URL=http://127.0.0.1:87883NEXT_PUBLIC_APP_ENV=development4NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8788
将下面的内容添加到 .gitignore 文件中:
apps/api/.dev.varsapps/web/.env.developmentapps/web/.env.testapps/web/.env.productionapps/admin/.env.developmentapps/admin/.env.testapps/admin/.env.production
如果确认这些环境变量都不涉及敏感信息,也可以按团队约定提交,但默认还是建议把真实文件排除掉。
第三件事,把 zod 收到根级版本管理。
这次新增了 server/client 四个 env helper,又有 contracts 包本来就在用 zod。既然已经变成多个子站共享依赖,就不该继续在每个 package 里手写一遍版本号。
可以直接把它收进 pnpm-workspace.yaml 的 catalog:
1catalog:2zod: ^4.1.12
然后各包统一改成:
1{2"dependencies": {3"zod": "catalog:"4}5}