TanStack Start 中文文档
服务器与执行

环境变量(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

解决方案

  1. 加上正确前缀:使用你构建工具的公开前缀(Vite 用 VITE_MY_VARIABLE,Rsbuild 用 PUBLIC_MY_VARIABLE
  2. 重启开发服务器(添加新变量之后)
  3. 检查文件位置.env 文件必须位于项目根目录
  4. 检查构建工具配置:确保变量被正确注入

示例

# ❌ 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
}

变量不更新

问题:环境变量的更改没有生效

解决方案

  1. 重启开发服务器
  2. 检查你是不是在改正确的 .env 文件
  3. 检查文件层级(.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
}

安全:密钥泄露到客户端

问题:敏感数据出现在客户端打包产物中

解决方案

  1. 从敏感变量上移除 VITE_PUBLIC_ 前缀
  2. 把敏感操作移到服务器函数中
  3. 用构建工具验证客户端打包产物中没有密钥

生产构建出错

问题:生产构建缺少环境变量

解决方案

  1. 在托管平台上配置变量
  2. 在构建时校验必需变量
  3. 使用部署专属的 .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 选项控制:

vite.config.ts
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(),
  ],
})

替换值按以下顺序确定:

  1. 构建时的 process.env.NODE_ENV(如果已设置)
  2. 构建工具的 mode(比如来自 --mode staging
  3. "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(以及可能的其他库)会以开发模式运行,这会显著变慢,并包含额外的警告和检查,不适合生产使用。

相关资源

On this page