본문으로 건너뛰기

환경 변수

다양한 컨텍스트(서버 함수, 클라이언트 코드 및 빌드 프로세스)에서 TanStack Start 애플리케이션의 환경 변수를 안전하게 구성하고 사용하는 방법을 알아봅니다.

환경 변수는 모듈 범위가 아니라 요청별로 읽으세요. Cloudflare Workers 및 기타 엣지 SSR 런타임에서는 환경 변수가 요청 시점에 주입되므로, 모듈 수준의 process.env.X 읽기는 환경이 존재하기 전에 실행되어 서버에서도 undefined으로 평가됩니다. 항상 process.env.handler(), 미들웨어 .server(), 서버 라우트 핸들러 또는 기타 요청별 콜백 내부에서 읽으세요. 모듈 범위에서 읽으면 비밀 값이 클라이언트 번들에 인라인될 위험도 있습니다. (특히 Cloudflare Workers에서는 모듈 범위를 포함해 어디서든 환경 변수를 읽는 표준 방식이 cloudflare:workers 환경 바인딩입니다.)

빠른 시작

TanStack Start는 .env 파일을 자동으로 로드하고 적절한 보안 경계를 적용하여 서버와 클라이언트 컨텍스트 모두에서 변수를 사용할 수 있게 합니다. 서버 코드는 process.env에서 접두사가 없는 변수를 읽을 수 있지만, 클라이언트 코드는 빌드 도구의 공개 접두사로 노출된 변수만 읽을 수 있습니다.

Vite

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

Rsbuild

# .env
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
PUBLIC_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 PUBLIC_ prefixed variables by default
export function AppHeader() {
return <h1>{import.meta.env.PUBLIC_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

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 />
}

Rsbuild

Rsbuild는 기본적으로 PUBLIC_ 접두사가 있는 변수를 노출합니다:

// Client configuration
export function ApiProvider({ children }: { children: React.ReactNode }) {
const apiUrl = import.meta.env.PUBLIC_API_URL // ✅ Public
const apiKey = import.meta.env.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.PUBLIC_ENABLE_NEW_FEATURE === 'true'

if (!enableNewFeature) return null

return <NewFeature />
}

환경 파일 설정

파일 계층 구조(로드 순서)

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을 생성합니다:

Vite

/// <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 {}

Rsbuild

/// <reference types="@rsbuild/core/types" />

interface ImportMetaEnv {
// Client-side environment variables
readonly PUBLIC_APP_NAME: string
readonly PUBLIC_API_URL: string
readonly PUBLIC_AUTH0_DOMAIN: string
readonly PUBLIC_AUTH0_CLIENT_ID: string
readonly PUBLIC_SENTRY_DSN?: string
readonly PUBLIC_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 is in .gitignore
  • 프로덕션 환경 변수가 호스팅 플랫폼에 구성되어 있습니다
  • 필수 환경 변수가 시작 시 검증됩니다
  • 소스 코드에 하드코딩된 시크릿이 없습니다
  • 프로덕션에서 Database URL은 연결 풀링을 사용합니다
  • API 키를 정기적으로 교체합니다

일반적인 문제

환경 변수가 정의되지 않음

문제: import.meta.env.MY_VARIABLEundefined을 반환합니다

해결 방법:

  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을 자동으로 대체하지만, 서버 빌드는 process.env이 실제 런타임 객체인 Node.js에서 실행됩니다. 정적 대체가 없으면 다음과 같은 코드가 프로덕션 서버 번들에 남게 됩니다:

if (process.env.NODE_ENV === 'development') {
// This code would NOT be eliminated without static replacement
enableDevTools()
logDebugInfo()
}

정적 대체가 활성화된 경우(기본값), 빌드 도구는 "production" === 'development'을 확인하고 전체 블록을 제거합니다.

정적 대체 구성

대체는 server.build.staticNodeEnv 옵션으로 제어됩니다:

Vite

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(),
],
})

Rsbuild

rsbuild.config.ts
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'

export default defineConfig({
plugins: [
pluginReact(),
tanstackStart({
server: {
build: {
// Replace process.env.NODE_ENV at build time (default: true)
staticNodeEnv: true,
},
},
}),
],
})

대체 값은 다음 순서로 결정됩니다:

  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
},
},
})

비활성화하는 일반적인 이유:

  • 동일한 빌드, 여러 환경: 하나의 빌드 아티팩트를 스테이징과 프로덕션에 배포합니다
  • 런타임 환경 감지: 실제 런타임 환경을 확인해야 하는 코드입니다
  • 프로덕션 빌드를 로컬에서 테스트: NODE_ENV=development로 프로덕션 빌드를 실행합니다

참고: 정적 치환을 비활성화하면 개발 전용 코드 경로가 프로덕션 번들에 남아 런타임에 평가됩니다.

중요: staticNodeEnv을 비활성화한 경우 프로덕션에서 서버를 실행할 때 런타임에 NODE_ENV=production반드시 설정해야 합니다. 설정하지 않으면 React(및 다른 라이브러리)가 개발 모드로 실행될 수 있으며, 이 모드는 훨씬 느리고 프로덕션용이 아닌 추가 경고와 검사를 포함합니다.