环境变量(Environment Variables)
学习如何在 TanStack Start 应用中跨不同场景(服务器函数、客户端代码、构建过程)安全地配置和使用环境变量(Environment Variables)。
重要
按请求读取环境变量,而不是在模块作用域读取。 在 Cloudflare Workers 和其他边缘 SSR 运行时中,环境变量是在请求时注入的——模块作用域里的 process.env.X 读取发生在环境变量存在之前,即使在服务端也会得到 undefined。始终在 .handler()、中间件的 .server()、服务器路由处理器或其他按请求执行的回调中读取 process.env。在模块作用域读取还会有把密钥内联进客户端打包产物的风险。(在 Cloudflare Workers 上,从任何地方——包括模块作用域——读取环境变量的标准方式是 cloudflare:workers env binding。)
快速上手
TanStack Start 会自动加载 .env 文件,并让变量在服务端和客户端环境中都可用,同时保持正确的安全边界。服务端代码可以从 process.env 读取无前缀的变量;客户端代码只能读取你的构建工具公开前缀(public prefix)暴露出来的变量。
# .env
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
VITE_APP_NAME=My TanStack Start App// Server function - can access any environment variable
const getUser = createServerFn().handler(async () => {
const db = await connect(process.env.DATABASE_URL) // ✅ Server-only
return db.user.findFirst()
})
// Client component - only VITE_ prefixed variables
export function AppHeader() {
return <h1>{import.meta.env.VITE_APP_NAME}</h1> // ✅ Client-safe
}环境变量的上下文
服务端上下文(服务器函数与 API 路由)
服务器函数可以使用 process.env 访问任何环境变量:
import { createServerFn } from '@tanstack/react-start'
// Database connection (server-only)
const connectToDatabase = createServerFn().handler(async () => {
const connectionString = process.env.DATABASE_URL // No prefix needed
const apiKey = process.env.EXTERNAL_API_SECRET // Stays on server
// These variables are never exposed to the client
return await database.connect(connectionString)
})
// Authentication (server-only)
const authenticateUser = createServerFn()
.validator(z.object({ token: z.string() }))
.handler(async ({ data }) => {
const jwtSecret = process.env.JWT_SECRET // Server-only
return jwt.verify(data.token, jwtSecret)
})客户端上下文(组件与客户端代码)
客户端代码只能访问带构建工具公开前缀(如 VITE_ / PUBLIC_)的变量。
Vite 暴露带 VITE_ 前缀的变量:
// Client configuration
export function ApiProvider({ children }: { children: React.ReactNode }) {
const apiUrl = import.meta.env.VITE_API_URL // ✅ Public
const apiKey = import.meta.env.VITE_PUBLIC_KEY // ✅ Public
// This would be undefined (security feature):
// const secret = import.meta.env.DATABASE_URL // ❌ Undefined
return (
<ApiContext.Provider value={{ apiUrl, apiKey }}>
{children}
</ApiContext.Provider>
)
}
// Feature flags
export function FeatureGatedComponent() {
const enableNewFeature = import.meta.env.VITE_ENABLE_NEW_FEATURE === 'true'
if (!enableNewFeature) return null
return <NewFeature />
}译者注:为什么客户端变量需要前缀?
这是前端构建工具的通行的安全机制:只有带公开前缀(VITE_ / PUBLIC_)的变量会被打包进客户端代码。没有前缀的变量(比如 DATABASE_URL)在客户端代码里读到的会是 undefined。所以任何不想暴露给用户的密钥,都不要加公开前缀,并且只在服务端代码里读取。
环境文件配置
文件层级(按加载顺序)
TanStack Start 会按以下顺序自动加载环境文件:
.env.local # Local overrides (add to .gitignore)
.env.production # Production-specific variables
.env.development # Development-specific variables
.env # Default variables (commit to git)示例配置
.env(提交到仓库):
# Public configuration (Vite uses VITE_; Rsbuild uses PUBLIC_ by default)
VITE_APP_NAME=My TanStack Start App
VITE_API_URL=https://api.example.com
VITE_SENTRY_DSN=https://...
PUBLIC_APP_NAME=My TanStack Start App
PUBLIC_API_URL=https://api.example.com
PUBLIC_SENTRY_DSN=https://...
# Server configuration templates
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379.env.local(加入 .gitignore):
# Override for local development
DATABASE_URL=postgresql://user:password@localhost:5432/myapp_local
STRIPE_SECRET_KEY=sk_test_...
JWT_SECRET=your-local-secret.env.production:
# Production overrides
VITE_API_URL=https://api.myapp.com
PUBLIC_API_URL=https://api.myapp.com
DATABASE_POOL_SIZE=20常见模式
数据库配置
// src/lib/database.ts
import { createServerFn } from '@tanstack/react-start'
const getDatabaseConnection = createServerFn().handler(async () => {
const config = {
url: process.env.DATABASE_URL,
maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '10'),
ssl: process.env.NODE_ENV === 'production',
}
return createConnection(config)
})认证服务商配置
// src/lib/auth.ts (Server)
export const authConfig = {
secret: process.env.AUTH_SECRET,
providers: {
auth0: {
domain: process.env.AUTH0_DOMAIN,
clientId: process.env.AUTH0_CLIENT_ID,
clientSecret: process.env.AUTH0_CLIENT_SECRET, // Server-only
}
}
}
// src/components/AuthProvider.tsx (Client)
export function AuthProvider({ children }: { children: React.ReactNode }) {
return (
<Auth0Provider
domain={import.meta.env.VITE_AUTH0_DOMAIN}
clientId={import.meta.env.VITE_AUTH0_CLIENT_ID}
// No client secret here - it stays on the server
>
{children}
</Auth0Provider>
)
}外部 API 集成
// src/lib/external-api.ts
import { createServerFn } from '@tanstack/react-start'
// Server-side API calls (can use secret keys)
const fetchUserData = createServerFn()
.validator(z.object({ userId: z.string() }))
.handler(async ({ data }) => {
const response = await fetch(
`${process.env.EXTERNAL_API_URL}/users/${data.userId}`,
{
headers: {
Authorization: `Bearer ${process.env.EXTERNAL_API_SECRET}`,
'Content-Type': 'application/json',
},
},
)
return response.json()
})
// Client-side API calls (public endpoints only)
export function usePublicData() {
const apiUrl = import.meta.env.VITE_PUBLIC_API_URL
return useQuery({
queryKey: ['public-data'],
queryFn: () => fetch(`${apiUrl}/public/stats`).then((r) => r.json()),
})
}特性开关与配置
// src/config/features.ts
export const featureFlags = {
enableNewDashboard: import.meta.env.VITE_ENABLE_NEW_DASHBOARD === 'true',
enableAnalytics: import.meta.env.VITE_ENABLE_ANALYTICS === 'true',
debugMode: import.meta.env.VITE_DEBUG_MODE === 'true',
}
// Usage in components
export function Dashboard() {
if (featureFlags.enableNewDashboard) {
return <NewDashboard />
}
return <LegacyDashboard />
}类型安全
TypeScript 声明
创建 src/env.d.ts 来添加类型安全:
/// <reference types="vite/client" />
interface ImportMetaEnv {
// Client-side environment variables
readonly VITE_APP_NAME: string
readonly VITE_API_URL: string
readonly VITE_AUTH0_DOMAIN: string
readonly VITE_AUTH0_CLIENT_ID: string
readonly VITE_SENTRY_DSN?: string
readonly VITE_ENABLE_NEW_DASHBOARD?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// Server-side environment variables
declare global {
namespace NodeJS {
interface ProcessEnv {
readonly DATABASE_URL: string
readonly REDIS_URL: string
readonly JWT_SECRET: string
readonly AUTH0_CLIENT_SECRET: string
readonly STRIPE_SECRET_KEY: string
readonly NODE_ENV: 'development' | 'production' | 'test'
}
}
}
export {}运行时校验
用 Zod 对环境变量做运行时校验:
// src/config/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.url(),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'production', 'test']),
})
const clientEnvSchema = z.object({
VITE_APP_NAME: z.string(),
VITE_API_URL: z.url(),
VITE_AUTH0_DOMAIN: z.string(),
VITE_AUTH0_CLIENT_ID: z.string(),
})
// Validate server environment
// NOTE: Module-level parse runs at module load. Fine for Node.js;
// on Cloudflare Workers (and other edge runtimes) `process.env` is
// empty at module load, so wrap this in a function and call it
// inside `.handler()` instead:
//
// export const getServerEnv = () => envSchema.parse(process.env)
//
// Then read `getServerEnv()` per-request from server functions/middleware.
export const serverEnv = envSchema.parse(process.env)
// Validate client environment (build-time, always safe)
export const clientEnv = clientEnvSchema.parse(import.meta.env)安全最佳实践
1. 绝不把密钥暴露给客户端
// ❌ WRONG - Secret exposed to client bundle
const config = {
apiKey: import.meta.env.VITE_SECRET_API_KEY, // This will be in your JS bundle!
}
// ✅ CORRECT - Keep secrets on server
const getApiData = createServerFn().handler(async () => {
const response = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${process.env.SECRET_API_KEY}` },
})
return response.json()
})2. 使用正确的前缀
# ✅ Server-only (no prefix)
DATABASE_URL=postgresql://...
JWT_SECRET=super-secret-key
STRIPE_SECRET_KEY=sk_live_...
# ✅ Client-safe (Vite uses VITE_; Rsbuild uses PUBLIC_ by default)
VITE_APP_NAME=My App
VITE_API_URL=https://api.example.com
VITE_SENTRY_DSN=https://...
PUBLIC_APP_NAME=My App
PUBLIC_API_URL=https://api.example.com
PUBLIC_SENTRY_DSN=https://...3. 校验必需变量
// src/config/validation.ts
const requiredServerEnv = ['DATABASE_URL', 'JWT_SECRET'] as const
const requiredClientEnv = ['VITE_APP_NAME', 'VITE_API_URL'] as const // Use PUBLIC_ names for Rsbuild
// Validate on server startup
for (const key of requiredServerEnv) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`)
}
}
// Validate client environment at build time
for (const key of requiredClientEnv) {
if (!import.meta.env[key]) {
throw new Error(`Missing required environment variable: ${key}`)
}
}生产检查清单
- 所有敏感变量都是仅服务端的(不带
VITE_或PUBLIC_前缀) - 客户端变量使用你构建工具的公开前缀(Vite 用
VITE_,Rsbuild 用PUBLIC_) -
.env.local在.gitignore中 - 生产环境变量已在托管平台上配置
- 必需的环境变量在启动时校验
- 源码中没有硬编码的密钥
- 生产环境中数据库 URL 使用连接池
- API 密钥定期轮换
常见问题
环境变量为 undefined
问题:import.meta.env.MY_VARIABLE 返回 undefined
解决方案:
- 加上正确前缀:使用你构建工具的公开前缀(Vite 用
VITE_MY_VARIABLE,Rsbuild 用PUBLIC_MY_VARIABLE) - 重启开发服务器(添加新变量之后)
- 检查文件位置:
.env文件必须位于项目根目录 - 检查构建工具配置:确保变量被正确注入
示例:
# ❌ Won't work in client code
API_KEY=abc123
# ✅ Works in client code
VITE_API_KEY=abc123
# ❌ Won't bundle the variable (assuming it is not set in the environment of the build)
npm run build
# ✅ Works in client code and will bundle the variable for production
VITE_API_KEY=abc123 npm run build生产环境的运行时客户端变量
问题:如果公开的客户端变量只在打包时替换,如何在客户端提供运行时变量?
解决方案:
把变量从服务端传给客户端:
const getRuntimeVar = createServerFn({ method: 'GET' }).handler(() => {
return process.env.MY_RUNTIME_VAR // notice `process.env` on the server, and no public prefix
})
export const Route = createFileRoute('/')({
loader: async () => {
const foo = await getRuntimeVar()
return { foo }
},
component: RouteComponent,
})
function RouteComponent() {
const { foo } = Route.useLoaderData()
// ... use your variable however you want
}变量不更新
问题:环境变量的更改没有生效
解决方案:
- 重启开发服务器
- 检查你是不是在改正确的
.env文件 - 检查文件层级(
.env.local覆盖.env)
TypeScript 错误
问题:Property 'VITE_MY_VAR' does not exist on type 'ImportMetaEnv' 或 Property 'PUBLIC_MY_VAR' does not exist on type 'ImportMetaEnv'
解决方案:添加到 src/env.d.ts:
interface ImportMetaEnv {
readonly VITE_MY_VAR: string
readonly PUBLIC_MY_VAR: string
}安全:密钥泄露到客户端
问题:敏感数据出现在客户端打包产物中
解决方案:
- 从敏感变量上移除
VITE_或PUBLIC_前缀 - 把敏感操作移到服务器函数中
- 用构建工具验证客户端打包产物中没有密钥
生产构建出错
问题:生产构建缺少环境变量
解决方案:
- 在托管平台上配置变量
- 在构建时校验必需变量
- 使用部署专属的
.env文件
服务端构建配置
静态 NODE_ENV 替换
默认情况下,TanStack Start 会在构建时对服务端构建中的 process.env.NODE_ENV 做静态替换。这使得服务端打包产物中仅用于开发的代码路径可以被死代码消除(tree-shaking)掉。
为什么这很重要: Vite 会自动替换客户端构建中的 process.env.NODE_ENV,但服务端构建运行在 Node.js 中,那里的 process.env 是真实的运行时对象。如果没有静态替换,类似这样的代码会留在你的生产服务端打包产物中:
if (process.env.NODE_ENV === 'development') {
// This code would NOT be eliminated without static replacement
enableDevTools()
logDebugInfo()
}启用静态替换(默认)后,构建工具看到的是 "production" === 'development',从而把整个代码块消除掉。
配置静态替换
该行为由 server.build.staticNodeEnv 选项控制:
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
tanstackStart({
server: {
build: {
// Replace process.env.NODE_ENV at build time (default: true)
staticNodeEnv: true,
},
},
}),
viteReact(),
],
})替换值按以下顺序确定:
- 构建时的
process.env.NODE_ENV(如果已设置) - 构建工具的
mode(比如来自--mode staging) "production"(兜底)
什么时候该禁用静态替换
如果你需要 NODE_ENV 在运行时保持动态,就设置 staticNodeEnv: false:
tanstackStart({
server: {
build: {
staticNodeEnv: false, // Keep NODE_ENV dynamic at runtime
},
},
})常见的禁用原因:
- 同一构建产物、多个环境:把一个构建产物同时部署到 staging 和生产
- 运行时环境检测:代码必须检查实际的运行时环境
- 本地测试生产构建:用
NODE_ENV=development运行生产构建
注意: 禁用静态替换意味着仅用于开发的代码路径会留在生产打包产物中,并在运行时被评估。
重要: 如果你禁用了
staticNodeEnv,在生产环境中运行服务器时必须在运行时设置NODE_ENV=production。否则 React(以及可能的其他库)会以开发模式运行,这会显著变慢,并包含额外的警告和检查,不适合生产使用。
相关资源
- 代码执行模式——了解服务端与客户端代码的执行
- 服务器函数——了解更多服务端代码
- 托管——各平台的专属环境变量配置
- Vite Environment Variables——Vite 官方文档
- Rsbuild Environment Variables——Rsbuild 官方文档