Learn how to securely configure and use environment variables in your TanStack Start application across different contexts (server functions, client code, and build processes).
TanStack Start automatically loads .env files and makes variables available in both server and client contexts with proper security boundaries.
# .env
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
VITE_APP_NAME=My TanStack Start App
# .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
}
// 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
}
Server functions can access any environment variable using 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()
.inputValidator(z.object({ token: z.string() }))
.handler(async ({ data }) => {
const jwtSecret = process.env.JWT_SECRET // Server-only
return jwt.verify(data.token, jwtSecret)
})
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()
.inputValidator(z.object({ token: z.string() }))
.handler(async ({ data }) => {
const jwtSecret = process.env.JWT_SECRET // Server-only
return jwt.verify(data.token, jwtSecret)
})
Client code can only access variables with the VITE_ prefix:
// 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 />
}
// 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 />
}
TanStack Start automatically loads environment files in this order:
.env.local # Local overrides (add to .gitignore)
.env.production # Production-specific variables
.env.development # Development-specific variables
.env # Default variables (commit to git)
.env.local # Local overrides (add to .gitignore)
.env.production # Production-specific variables
.env.development # Development-specific variables
.env # Default variables (commit to git)
.env (committed to repository):
# Public configuration
VITE_APP_NAME=My TanStack Start App
VITE_API_URL=https://api.example.com
VITE_SENTRY_DSN=https://...
# Server configuration templates
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
# Public configuration
VITE_APP_NAME=My TanStack Start App
VITE_API_URL=https://api.example.com
VITE_SENTRY_DSN=https://...
# Server configuration templates
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
.env.local (add to .gitignore):
# Override for local development
DATABASE_URL=postgresql://user:password@localhost:5432/myapp_local
STRIPE_SECRET_KEY=sk_test_...
JWT_SECRET=your-local-secret
# 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
DATABASE_POOL_SIZE=20
# Production overrides
VITE_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/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>
)
}
// 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>
)
}
// src/lib/external-api.ts
import { createServerFn } from '@tanstack/react-start'
// Server-side API calls (can use secret keys)
const fetchUserData = createServerFn()
.inputValidator(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/lib/external-api.ts
import { createServerFn } from '@tanstack/react-start'
// Server-side API calls (can use secret keys)
const fetchUserData = createServerFn()
.inputValidator(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 />
}
// 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 />
}
Create src/env.d.ts to add type safety:
/// <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 {}
/// <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 {}
Use Zod for runtime validation of environment variables:
// src/config/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().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.string().url(),
VITE_AUTH0_DOMAIN: z.string(),
VITE_AUTH0_CLIENT_ID: z.string(),
})
// Validate server environment
export const serverEnv = envSchema.parse(process.env)
// Validate client environment
export const clientEnv = clientEnvSchema.parse(import.meta.env)
// src/config/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().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.string().url(),
VITE_AUTH0_DOMAIN: z.string(),
VITE_AUTH0_CLIENT_ID: z.string(),
})
// Validate server environment
export const serverEnv = envSchema.parse(process.env)
// Validate client environment
export const clientEnv = clientEnvSchema.parse(import.meta.env)
// ❌ 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()
})
// ❌ 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()
})
# ✅ Server-only (no prefix)
DATABASE_URL=postgresql://...
JWT_SECRET=super-secret-key
STRIPE_SECRET_KEY=sk_live_...
# ✅ Client-safe (VITE_ prefix)
VITE_APP_NAME=My App
VITE_API_URL=https://api.example.com
VITE_SENTRY_DSN=https://...
# ✅ Server-only (no prefix)
DATABASE_URL=postgresql://...
JWT_SECRET=super-secret-key
STRIPE_SECRET_KEY=sk_live_...
# ✅ Client-safe (VITE_ prefix)
VITE_APP_NAME=My App
VITE_API_URL=https://api.example.com
VITE_SENTRY_DSN=https://...
// src/config/validation.ts
const requiredServerEnv = ['DATABASE_URL', 'JWT_SECRET'] as const
const requiredClientEnv = ['VITE_APP_NAME', 'VITE_API_URL'] as const
// 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}`)
}
}
// src/config/validation.ts
const requiredServerEnv = ['DATABASE_URL', 'JWT_SECRET'] as const
const requiredClientEnv = ['VITE_APP_NAME', 'VITE_API_URL'] as const
// 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}`)
}
}
Problem: import.meta.env.MY_VARIABLE returns undefined
Solutions:
Example:
# ❌ 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
# ❌ 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
Problem: If VITE_ variables are replaced at bundle time only, how to make runtime variables available on the client?
Solutions:
Pass variables from the server down to the client:
const getRuntimeVar = createServerFn({ method: 'GET' }).handler(() => {
return process.env.MY_RUNTIME_VAR // notice `process.env` on the server, and no `VITE_` 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
}
const getRuntimeVar = createServerFn({ method: 'GET' }).handler(() => {
return process.env.MY_RUNTIME_VAR // notice `process.env` on the server, and no `VITE_` 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
}
Problem: Environment variable changes aren't reflected
Solutions:
Problem: Property 'VITE_MY_VAR' does not exist on type 'ImportMetaEnv'
Solution: Add to src/env.d.ts:
interface ImportMetaEnv {
readonly VITE_MY_VAR: string
}
interface ImportMetaEnv {
readonly VITE_MY_VAR: string
}
Problem: Sensitive data appearing in client bundle
Solutions:
Problem: Missing environment variables in production build
Solutions:
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.