Skip to content

环境变量配置

要点

  • 先把 Next.js 自己的规则讲清楚,不然后面项目里的配置策略很容易写偏
  • Hono 本身只是路由框架,真正的环境变量入口取决于它跑在哪
  • 只改页面和脚本还不够,任务调度层和示例文件层也得同步,否则后面会继续冒出隐性问题

内容

1. 概述

环境变量本质上是一组注入到运行时环境里的配置值,用来告诉应用当前该连接什么环境、运行在哪、打开什么能力。它和写死在代码里的常量不同,环境变量的配置可以随着开发、联调、生产环境切换。

这个项目里,环境变量最直接承担两类职责:

  • 标记当前业务运行语义,前端使用 NEXT_PUBLIC_APP_ENV,API Worker 使用 APP_ENV
  • 提供前端外部依赖地址,比如 NEXT_PUBLIC_API_BASE_URL

当前 webadmin 的服务端、浏览器会共用同一组 NEXT_PUBLIC_* 公开配置,避免同一个值用两个名称重复维护。api 侧的 APP_ENV 是 Worker 自身运行配置,职责不同,仍然保留。

所以目标很明确:

  • Web 与 Admin 的公开环境变量保持单一真源
  • 同时覆盖 Next.js 服务端和客户端组件
  • 显式区分 developmenttestproduction 三种环境
  • 去掉页面代码里的硬编码回退地址
  • 让 API、Web、Admin 都真正消费同一套环境约定
  • 顺带把 zod 收敛到工作区根级 catalog 管理

2. Next.js 如何读取环境变量

先把 Next.js 自己的规则讲清楚,不然后面项目里的配置策略很容易写偏。

NOTE

如果你对 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 对应的是 developmentnext buildnext 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:

typescript
// apps/web/src/config/env/client.ts
import { z } from 'zod'

const webClientEnvSchema = z.object({

  NEXT_PUBLIC_APP_ENV: z.enum(['development', 'test', 'production']),

  NEXT_PUBLIC_API_BASE_URL: z.string().url(),

})

export type WebClientEnv = z.infer<typeof webClientEnvSchema>

export function getWebClientEnv(): WebClientEnv {

  return webClientEnvSchema.parse({

    NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,

    NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,

  })

}

这里还有一个官方文档专门提过的点:读取时直接写 process.env.NEXT_PUBLIC_APP_ENV 这种属性访问,不要先解构 process.env 再用。

本地开发时,webadmin 最常见的配置文件会是这些:

  • apps/web/.env.development
  • apps/web/.env.test
  • apps/admin/.env.development
  • apps/admin/.env.test

例如 web 侧本地开发:

shellscript
// apps/web/.env.development
NEXT_PUBLIC_APP_ENV=development

NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8788

2.3 客户端为什么只能用 NEXT_PUBLIC_*

客户端组件跑在浏览器里,浏览器拿不到 Node.js 的完整进程环境。为了避免把私有变量直接暴露出去,Next.js 只会把带 NEXT_PUBLIC_ 前缀的环境变量内联到发送给浏览器的 JavaScript 里。

这意味着两件事。

第一,客户端只能访问 NEXT_PUBLIC_*。

因此前端共用的环境语义和 API 地址直接定义为 NEXT_PUBLIC_APP_ENVNEXT_PUBLIC_API_BASE_URL,服务端同样复用,不再维护无前缀别名。

第二,NEXT_PUBLIC_ 是在前端代码编译时写进去的。*

也就是说,客户端看到的是 next devnext build 当时确定下来的值,不是浏览器运行时再去现查操作系统环境变量。

客户端 helper 可以这样写:

typescript
// apps/web/src/config/env/client.ts
import { z } from 'zod'

const webClientEnvSchema = z.object({

  NEXT_PUBLIC_APP_ENV: z.enum(['development', 'test', 'production']),

  NEXT_PUBLIC_API_BASE_URL: z.string().url(),

})

export type WebClientEnv = z.infer<typeof webClientEnvSchema>

export function getWebClientEnv(): WebClientEnv {

  return webClientEnvSchema.parse({

    NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,

    NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,

  })

}

使用时要记住一条边界:如果将来新增真正的服务端密钥,再创建 config/env/server.ts,且不能引入 "use client" 组件。

例如 web 首页本身是 Server Component,但它使用的环境语义和 API 地址仍是公开配置:

tsx
// apps/web/app/page.tsx
import { getWebClientEnv } from '@/config/env/client'

import { ClientEnvBadge } from './_components/client-env-badge'

async function getPingResponse(apiBaseUrl: string): Promise<PingRpcResponse> {

  const client = hc<AppType>(apiBaseUrl)

  const response = await client.rpc.system.ping.$post({

    json: rpcPayload,

  })

  return await response.json()

}

export default async function Home() {

  const env = getWebClientEnv()

  const pingResult = await getPingResponse(env.NEXT_PUBLIC_API_BASE_URL)

  return (

    <section>

      <span>server {env.NEXT_PUBLIC_APP_ENV}</span>

      <span>{env.NEXT_PUBLIC_API_BASE_URL}</span>

      <ClientEnvBadge />

    </section>

  )

}

如果页面需要展示客户端环境信息,可以把展示逻辑放在页面专属组件中。这个组件只读取公开变量:

tsx
// 示例:apps/web/app/_components/client-env-badge.tsx
"use client"

import { getWebClientEnv } from '@/config/env/client'

export function ClientEnvBadge() {

  const env = getWebClientEnv()

  return (

    <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">

      <span className="rounded-full border border-border px-3 py-1">

        client {env.NEXT_PUBLIC_APP_ENV}

      </span>

      <span className="rounded-full border border-border px-3 py-1">

        {env.NEXT_PUBLIC_API_BASE_URL}

      </span>

    </div>

  )

}

3. Hono API 怎么读取环境变量

Hono 本身只是路由框架,真正的环境变量入口取决于它跑在哪。

这个项目里的 API 跑在 Cloudflare Worker,所以读取入口不是 process.env,而是 c.env

先看配置入口。wrangler.jsonc 负责定义默认环境和具名环境:

jsonc
// apps/api/wrangler.jsonc
{

  "$schema": "node_modules/wrangler/config-schema.json",

  "name": "api",

  "main": "src/index.ts",

  "compatibility_date": "2026-04-22",

  "vars": {

    "APP_ENV": "development"

  },

  "env": {

    "test": {

      "vars": {

        "APP_ENV": "test"

      }

    },

    "production": {

      "vars": {

        "APP_ENV": "production"

      }

    }

  }

}

这里的含义是:

  • 顶层 vars 给默认开发环境
  • env.test 给联调环境
  • env.production 给生产环境

本地开发还要配 apps/api/.dev.vars,Wrangler 启动时会把它注入 Worker 运行时。

shellscript
// apps/api/.dev.vars
APP_ENV=development

配置完以后,路由里不要直接散着读 c.env.APP_ENV,最好先集中校验一次:

typescript
// apps/api/src/env.ts
import { z } from 'zod'

const apiEnvSchema = z.object({

  APP_ENV: z.enum(['development', 'test', 'production']),

})

export type ApiEnv = z.infer<typeof apiEnvSchema>

export function getApiEnv(bindings: Record<string, unknown>): ApiEnv {

  return apiEnvSchema.parse({

    APP_ENV: bindings.APP_ENV,

  })

}

然后在 Hono 路由里通过 c.env 读取:

typescript
// apps/api/src/app.ts
import { getApiEnv } from './env'

const app = new Hono<{

  Bindings: {

    APP_ENV: 'development' | 'test' | 'production'

  }

}>()

const routes = app

  .get('/health', (c) => {

    const env = getApiEnv(c.env)

    return c.json(

      buildSuccess(

        {

          service: 'api',

          env: env.APP_ENV,

        },

        createMeta(),

      ),

    )

  })

  .post('/rpc/system/ping', validator('json', (value, c) => {

      const parsed = PingRequestSchema.safeParse(value)

      if (!parsed.success) {

        return c.json(

          buildFailure(

            {

              code: BizCode.COMMON_INVALID_REQUEST,

              message: 'Invalid request payload',

              details: parsed.error.flatten(),

            },

            createMeta(),

          ),

          400,

        )

      }

      return parsed.data

    }),

    (c) => {

      const payload = c.req.valid('json')

      const env = getApiEnv(c.env)

      return c.json(

        buildSuccess(

          {

            service: 'api',

            message: `pong, ${payload.name}`,

            env: env.APP_ENV,

          },

          createMeta(),

        ),

      )

    },

  )

Hono 这一层的结论很明确:

  • Worker 环境变量来自 wrangler.jsonc.dev.vars 和 secret
  • 运行时通过 c.env 读取
  • 最好先经过一个 env.ts 做校验

4. 回到这个项目,环境变量如何设计

把 Next.js 和 Hono 各自的读取方式讲清之后,再看这个项目的方案就顺了。

当前前端统一使用两项公开环境变量:

  • NEXT_PUBLIC_APP_ENV
  • NEXT_PUBLIC_API_BASE_URL

服务端与浏览器都通过同一个 client-safe helper 读取它们。只有将来真正引入服务端密钥时,才需要额外创建 config/env/server.ts

这样设计有两个直接好处。

第一,同一个配置只维护一次,避免服务端和浏览器取到不同值。

第二,私有密钥仍然不带 NEXT_PUBLIC_ 前缀,公开与私有配置边界保持清晰。

这里不新建共享 env package,原因也很直接:

  • api 跑在 Worker,读的是 c.env
  • webadmin 跑在 Next 服务端和浏览器,读的是 process.env

运行时入口本来就不同,API Worker 的 APP_ENV 不是前端别名,不应为了形式统一而改名。

admin 侧同样只保留 env.client.ts,服务端和浏览器都复用它:

tsx
// apps/admin/app/page.tsx
import { getAdminClientEnv } from '../src/env.client'

export default function Home() {

  const env = getAdminClientEnv()

  return (

    <Card>

      <CardHeader>

        <CardTitle>Environment overview</CardTitle>

        <CardDescription>

          The admin app shares one public environment contract across runtimes.

        </CardDescription>

      </CardHeader>

      <CardContent className="space-y-4">

        <div className="grid gap-3 md:grid-cols-2">

          <div>

            <p>NEXT_PUBLIC_APP_ENV</p>

            <p>{env.NEXT_PUBLIC_APP_ENV}</p>

          </div>

          <div>

            <p>NEXT_PUBLIC_API_BASE_URL</p>

            <p>{env.NEXT_PUBLIC_API_BASE_URL}</p>

          </div>

        </div>

      </CardContent>

    </Card>

  )

}

5. Turbo、示例文件修改

只改页面和脚本还不够,任务调度层和示例文件层也得同步,否则后面会继续冒出隐性问题。

第一件事,Turbo 要声明环境变量。

turbo.json 里的 buildbuild:teststart:testdevdev:testlintcheck-types 都应该声明两项前端变量:

  • NEXT_PUBLIC_APP_ENV
  • NEXT_PUBLIC_API_BASE_URL

这样可以同时解决两个问题:

  • turbo/no-undeclared-env-vars 告警
  • Turbo 缓存没有感知环境变量变化
json
// turbo.json
{

  "tasks": {

    "build": {

      "dependsOn": ["^build"],

      "inputs": ["$TURBO_DEFAULT$", ".env*"],

      "outputs": [".next/**", "!.next/cache/**"],

      "env": ["NEXT_PUBLIC_APP_ENV", "NEXT_PUBLIC_API_BASE_URL"]

    },

    "lint": {

      "dependsOn": ["^lint"],

      "env": ["NEXT_PUBLIC_APP_ENV", "NEXT_PUBLIC_API_BASE_URL"]

    },

    "check-types": {

      "dependsOn": ["^check-types"],

      "env": ["NEXT_PUBLIC_APP_ENV", "NEXT_PUBLIC_API_BASE_URL"]

    },

    "dev": {

      "cache": false,

      "persistent": true,

      "env": ["NEXT_PUBLIC_APP_ENV", "NEXT_PUBLIC_API_BASE_URL"]

    }

  }

}

第二件事,示例文件和真实文件分层。

由于环境变量有可能涉及敏感信息,因此 git 仓库里只提交示例文件,不提交真实环境文件:

  • apps/api/.dev.vars.example
  • apps/web/.env.example
  • apps/admin/.env.example

前端示例文件只保留公开配置的单一真源:

shellscript
// apps/web/.env.example
NEXT_PUBLIC_APP_ENV=development

NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8787

# For pre-release / integration testing

# NEXT_PUBLIC_APP_ENV=test

# NEXT_PUBLIC_API_BASE_URL=https://test-api.example.com

# For production

# NEXT_PUBLIC_APP_ENV=production

# NEXT_PUBLIC_API_BASE_URL=https://api.example.com

真实的环境变量配置文件需要依据示例文件创建,例如:

shellscript
// apps/api/.dev.vars
APP_ENV=development
shellscript
// apps/web/.env.development
NEXT_PUBLIC_APP_ENV=development

NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8788

将下面的内容添加到 .gitignore 文件中:

  • apps/api/.dev.vars
  • apps/web/.env.development
  • apps/web/.env.test
  • apps/web/.env.production
  • apps/admin/.env.development
  • apps/admin/.env.test
  • apps/admin/.env.production

NOTE

如果确认这些环境变量都不涉及敏感信息,也可以按团队约定提交,但默认还是建议把真实文件排除掉。

第三件事,把 zod 收到根级版本管理。

这次 Web 和 Admin 都使用 Zod 校验 env,contracts 包也本来就在用 zod。既然已经变成多个子站共享依赖,就不该继续在每个 package 里手写一遍版本号。

可以直接把它收进 pnpm-workspace.yamlcatalog

yaml
// pnpm-workspace.yaml
catalog:

  zod: ^4.1.12

然后各包统一改成:

json
// package.json
{

  "dependencies": {

    "zod": "catalog:"

  }

}

基于 MIT 协议开源