Docs
CodeRabbit
Cloudflare
AG Grid
Netlify
Neon
WorkOS
Clerk
Convex
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
Netlify
Neon
WorkOS
Clerk
Convex
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Integrations

How to Use Environment Variables

Learn how to configure and use environment variables in your TanStack Router application for API endpoints, feature flags, and build configuration across different bundlers.

Quick Start

Environment variables in TanStack Router are primarily used for client-side configuration and must follow bundler-specific naming conventions for security.

sh
# .env
VITE_API_URL=https://api.example.com
VITE_ENABLE_DEVTOOLS=true
typescript
// Route configuration
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts')({
  loader: async () => {
    const apiUrl = import.meta.env.VITE_API_URL
    const response = await fetch(`${apiUrl}/posts`)
    return response.json()
  },
  component: PostsList,
})

Environment Variable Access Patterns

Vite-Based Projects (Most Common)

With Vite, environment variables must be prefixed with VITE_ to be accessible in client code:

typescript
// Route loaders
export const Route = createFileRoute('/dashboard')({
  loader: async () => {
    const apiUrl = import.meta.env.VITE_API_URL        // ✅ Works
    const apiKey = import.meta.env.VITE_PUBLIC_API_KEY // ✅ Works

    // This would be undefined (security feature):
    // const secret = import.meta.env.SECRET_KEY        // ❌ Undefined

    return fetchDashboardData(apiUrl, apiKey)
  },
})

// Components
export function ApiStatus() {
  const isDev = import.meta.env.DEV           // ✅ Built-in Vite variable
  const isProd = import.meta.env.PROD         // ✅ Built-in Vite variable
  const mode = import.meta.env.MODE           // ✅ development/production

  return (
    <div>
      Environment: {mode}
      {isDev && <DevToolsPanel />}
    </div>
  )
}

Webpack-Based Projects

Configure webpack's DefinePlugin to inject environment variables. Note: Webpack doesn't support import.meta.env by default, so use process.env patterns:

typescript
// webpack.config.js
const webpack = require('webpack')

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.API_URL': JSON.stringify(process.env.API_URL),
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
      'process.env.ENABLE_FEATURE': JSON.stringify(process.env.ENABLE_FEATURE),
    }),
  ],
}

// Usage in routes
export const Route = createFileRoute('/api-data')({
  loader: async () => {
    const response = await fetch(`${process.env.API_URL}/data`)
    return response.json()
  },
  component: () => {
    const enableFeature = process.env.ENABLE_FEATURE === 'true'
    return enableFeature ? <NewFeature /> : <OldFeature />
  },
})

Rspack-Based Projects

Rspack uses the PUBLIC_ prefix convention. Note: import.meta.env support depends on your Rspack configuration and runtime - you may need to configure builtins.define properly:

sh
# .env
PUBLIC_API_URL=https://api.example.com
PUBLIC_FEATURE_FLAG=true
typescript
// Route usage
export const Route = createFileRoute('/features')({
  loader: async () => {
    const apiUrl = import.meta.env.PUBLIC_API_URL
    return fetch(`${apiUrl}/features`).then(r => r.json())
  },
  component: () => {
    const enableFeature = import.meta.env.PUBLIC_FEATURE_FLAG === 'true'
    return enableFeature ? <NewFeature /> : <OldFeature />
  },
})

ESBuild Projects

Configure defines manually:

typescript
// build script
import { build } from 'esbuild'

await build({
  entryPoints: ['src/main.tsx'],
  define: {
    'process.env.NODE_ENV': '"production"',
    'process.env.API_URL': `"${process.env.API_URL}"`,
  },
})

Common Patterns

API Configuration in Route Loaders

typescript
// src/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'

const fetchPosts = async () => {
  const baseUrl = import.meta.env.VITE_API_URL
  const apiKey = import.meta.env.VITE_API_KEY

  const response = await fetch(`${baseUrl}/posts`, {
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
  })

  if (!response.ok) {
    throw new Error('Failed to fetch posts')
  }

  return response.json()
}

export const Route = createFileRoute('/posts/')({
  loader: fetchPosts,
  errorComponent: ({ error }) => (
    <div>Error loading posts: {error.message}</div>
  ),
})

Environment-Based Route Configuration

typescript
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'

export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
      {/* Only show devtools in development */}
      {import.meta.env.DEV && <TanStackRouterDevtools />}
    </>
  ),
})

Feature Flags in Routes

typescript
// src/lib/features.ts
export const features = {
  enableNewDashboard: import.meta.env.VITE_ENABLE_NEW_DASHBOARD === 'true',
  enableAnalytics: import.meta.env.VITE_ENABLE_ANALYTICS === 'true',
  debugMode: import.meta.env.DEV,
}

// src/routes/dashboard/index.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { features } from '../../lib/features'

export const Route = createFileRoute('/dashboard/')({
  beforeLoad: () => {
    // Redirect to old dashboard if new one is disabled
    if (!features.enableNewDashboard) {
      throw redirect({ to: '/dashboard/legacy' })
    }
  },
  component: NewDashboard,
})

Authentication Configuration

typescript
// src/lib/auth.ts
export const authConfig = {
  domain: import.meta.env.VITE_AUTH0_DOMAIN,
  clientId: import.meta.env.VITE_AUTH0_CLIENT_ID,
  redirectUri: `${window.location.origin}/callback`,
}

// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { authConfig } from '../lib/auth'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: async ({ location }) => {
    const isAuthenticated = await checkAuthStatus()

    if (!isAuthenticated) {
      // Redirect to auth provider
      const authUrl = `https://${authConfig.domain}/authorize?client_id=${authConfig.clientId}&redirect_uri=${authConfig.redirectUri}`
      window.location.href = authUrl
      return
    }
  },
})

Search Params with Environment Config

typescript
// src/routes/search.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

const searchSchema = z.object({
  q: z.string().optional(),
  category: z.string().optional(),
})

export const Route = createFileRoute('/search')({
  validateSearch: searchSchema,
  loader: async ({ search }) => {
    const apiUrl = import.meta.env.VITE_SEARCH_API_URL
    const params = new URLSearchParams({
      q: search.q || '',
      category: search.category || 'all',
      api_key: import.meta.env.VITE_SEARCH_API_KEY,
    })

    const response = await fetch(`${apiUrl}/search?${params}`)
    return response.json()
  },
})

Environment File Setup

File Hierarchy (Vite)

Vite loads environment files in this order:

.env.local          # Local overrides (add to .gitignore)
.env.production     # Production-specific
.env.development    # Development-specific
.env                # Default (commit to git)

Example Configuration

.env (committed to repository):

sh
# API Configuration
VITE_API_URL=https://api.example.com
VITE_API_VERSION=v1

# Feature Flags
VITE_ENABLE_NEW_UI=false
VITE_ENABLE_ANALYTICS=true

# Auth Configuration (public keys only)
VITE_AUTH0_DOMAIN=your-domain.auth0.com
VITE_AUTH0_CLIENT_ID=your-client-id

# Build Configuration
VITE_APP_NAME=TanStack Router App
VITE_APP_VERSION=1.0.0

.env.local (add to .gitignore):

sh
# Development overrides
VITE_API_URL=http://localhost:3001
VITE_ENABLE_NEW_UI=true
VITE_DEBUG_MODE=true

.env.production:

sh
# Production-specific
VITE_API_URL=https://api.prod.example.com
VITE_ENABLE_ANALYTICS=true
VITE_ENABLE_NEW_UI=true

Type Safety

Vite TypeScript Declarations

Create src/vite-env.d.ts:

typescript
/// <reference types="vite/client" />

interface ImportMetaEnv {
  // API Configuration
  readonly VITE_API_URL: string
  readonly VITE_API_VERSION: string
  readonly VITE_API_KEY?: string

  // Feature Flags
  readonly VITE_ENABLE_NEW_UI: string
  readonly VITE_ENABLE_ANALYTICS: string
  readonly VITE_DEBUG_MODE?: string

  // Authentication
  readonly VITE_AUTH0_DOMAIN: string
  readonly VITE_AUTH0_CLIENT_ID: string

  // App Configuration
  readonly VITE_APP_NAME: string
  readonly VITE_APP_VERSION: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

Runtime Validation

Use Zod to validate environment variables at startup with fallbacks and optional values:

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

const envSchema = z.object({
  // Required variables
  VITE_API_URL: z.string().url(),
  VITE_AUTH0_DOMAIN: z.string(),
  VITE_AUTH0_CLIENT_ID: z.string(),
  VITE_APP_NAME: z.string(),

  // Optional with defaults
  VITE_API_VERSION: z.string().default('v1'),
  VITE_ENABLE_NEW_UI: z.string().default('false'),
  VITE_ENABLE_ANALYTICS: z.string().default('true'),

  // Optional variables
  VITE_DEBUG_MODE: z.string().optional(),
  VITE_SENTRY_DSN: z.string().optional(),
})

// Validate at app startup with fallbacks
export const env = envSchema.parse({
  ...import.meta.env,
  // Provide fallbacks for missing optional values
  VITE_API_VERSION: import.meta.env.VITE_API_VERSION || 'v1',
  VITE_ENABLE_NEW_UI: import.meta.env.VITE_ENABLE_NEW_UI || 'false',
  VITE_ENABLE_ANALYTICS: import.meta.env.VITE_ENABLE_ANALYTICS || 'true',
})

// Typed helper functions
export const isFeatureEnabled = (flag: keyof typeof env) => {
  return env[flag] === 'true'
}

// Type-safe boolean conversion
export const getBooleanEnv = (
  value: string | undefined,
  defaultValue = false,
): boolean => {
  if (value === undefined) return defaultValue
  return value === 'true'
}

Usage with Type Safety

typescript
// src/routes/api-data.tsx
import { createFileRoute } from '@tanstack/react-router'
import { env, isFeatureEnabled } from '../config/env'

export const Route = createFileRoute('/api-data')({
  loader: async () => {
    // TypeScript knows these are strings and exist
    const response = await fetch(`${env.VITE_API_URL}/${env.VITE_API_VERSION}/data`)
    return response.json()
  },
  component: () => {
    return (
      <div>
        <h1>{env.VITE_APP_NAME}</h1>
        {isFeatureEnabled('VITE_ENABLE_NEW_UI') && <NewUIComponent />}
      </div>
    )
  },
})

Bundler-Specific Configuration

Vite Configuration

typescript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'

export default defineConfig({
  plugins: [
    react(),
    // tanstackRouter generates route tree and enables file-based routing
    tanstackRouter(),
  ],
  // Environment variables are handled automatically
  // Custom environment variable handling:
  define: {
    // Global constants (these become available as global variables)
    __APP_VERSION__: JSON.stringify(process.env.npm_package_version),
  },
})

Webpack Configuration

typescript
// webpack.config.js
const { TanStackRouterWebpack } = require('@tanstack/router-webpack-plugin')
const webpack = require('webpack')

module.exports = {
  plugins: [
    // TanStackRouterWebpack generates route tree and enables file-based routing
    new TanStackRouterWebpack(),
    new webpack.DefinePlugin({
      // Inject environment variables (use process.env for Webpack)
      'process.env.API_URL': JSON.stringify(process.env.API_URL),
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
      'process.env.ENABLE_FEATURE': JSON.stringify(process.env.ENABLE_FEATURE),
    }),
  ],
}

Rspack Configuration

typescript
// rspack.config.js
const { TanStackRouterRspack } = require('@tanstack/router-rspack-plugin')

module.exports = {
  plugins: [
    // TanStackRouterRspack generates route tree and enables file-based routing
    new TanStackRouterRspack(),
  ],
  // Rspack automatically handles PUBLIC_ prefixed variables for import.meta.env
  // Custom handling for additional variables:
  builtins: {
    define: {
      // Define additional variables (these become global replacements)
      'process.env.API_URL': JSON.stringify(process.env.PUBLIC_API_URL),
      __BUILD_TIME__: JSON.stringify(new Date().toISOString()),
    },
  },
}

Production Checklist

  • All client-exposed variables use appropriate prefix (VITE_, PUBLIC_, etc.)
  • No sensitive data (API secrets, private keys) in environment variables
  • .env.local is in .gitignore
  • Production environment variables are configured on hosting platform
  • Required environment variables are validated at build time
  • TypeScript declarations are up to date
  • Feature flags are properly configured for production
  • API URLs point to production endpoints

Common Problems

Environment Variable is Undefined

Problem: import.meta.env.MY_VARIABLE returns undefined

Solutions:

  1. Add correct prefix: Use VITE_ for Vite, PUBLIC_ for Rspack. Vite's default prefix may be changed in the config:
    ts
    // vite.config.ts
    export const config = {
      // ...rest of your config
      envPrefix: 'MYPREFIX_', // this means `MYPREFIX_MY_VARIABLE` is the new correct way
    }
    
  2. Restart development server after adding new variables
  3. Check file location: .env file must be in project root
  4. Verify bundler configuration: Ensure variables are properly injected
  5. Verify variable:
  • In dev: is in correct .env file or environment
  • For prod: is in correct .env file or current environment at bundle time. That's right, VITE_/PUBLIC_-prefixed variables are replaced in a macro-like fashion at bundle time, and will never be read at runtime on your server. This is a common mistake, so make sure this is not your case.

Example:

sh
# ❌ Won't work (no prefix)
API_KEY=abc123

# ✅ Works with Vite
VITE_API_KEY=abc123

# ✅ Works with Rspack
PUBLIC_API_KEY=abc123

# ❌ Won't bundle the variable (assuming it is not set in the environment of the build)
npm run build

# ✅ Works with Vite and will bundle the variable for production
VITE_API_KEY=abc123 npm run build

# ✅ Works with Rspack and will bundle the variable for production
PUBLIC_API_KEY=abc123 npm run build

Runtime Client Environment Variables at Runtime in Production

Problem: If VITE_/PUBLIC_ 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:

  1. Add your variable to the correct env. file
  2. Create an endpoint on your server to read the value from the client

Example:

You may use your prefered backend framework/libray, but here it is using Tanstack Start server functions:

tsx
const getRuntimeVar = createServerFn({ method: 'GET' }).handler(() => {
  return process.env.MY_RUNTIME_VAR // notice `process.env` on the server, and no `VITE_`/`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
}

Variable Not Updating

Problem: Environment variable changes aren't reflected in app

Solutions:

  1. Restart development server - Required for new variables
  2. Check file hierarchy - .env.local overrides .env
  3. Clear browser cache - Hard refresh (Ctrl+Shift+R)
  4. Verify correct file - Make sure you're editing the right .env file

TypeScript Errors

Problem: Property 'VITE_MY_VAR' does not exist on type 'ImportMetaEnv'

Solution: Add declaration to src/vite-env.d.ts:

typescript
interface ImportMetaEnv {
  readonly VITE_MY_VAR: string
}

Build Errors

Problem: Missing environment variables during build

Solutions:

  1. Configure CI/CD: Set variables in build environment
  2. Add validation: Check required variables at build time
  3. Use .env files: Ensure production .env files exist
  4. Check bundler config: Verify environment variable injection

Security Issues

Problem: Accidentally exposing sensitive data

Solutions:

  1. Never use secrets in client variables - They're visible in browser
  2. Use server-side proxies for sensitive API calls
  3. Audit bundle - Check built files for leaked secrets
  4. Follow naming conventions - Only prefixed variables are exposed

Runtime vs Build-time Confusion

Problem: Variables not available at runtime

Solutions:

  1. Understand static replacement - Variables are replaced at build time
  2. Use server-side for dynamic values - Use APIs for runtime configuration
  3. Validate at startup - Check all required variables exist

Environment Variables are Always Strings

Problem: Unexpected behavior when comparing boolean or numeric values

Solutions:

  1. Always compare as strings: Use === 'true' not === true
  2. Convert explicitly: Use parseInt(), parseFloat(), or Boolean()
  3. Use helper functions: Create typed conversion utilities

Example:

typescript
// ❌ Won't work as expected
const isEnabled = import.meta.env.VITE_FEATURE_ENABLED // This is a string!
if (isEnabled) {
  /* Always true if variable exists */
}

// ✅ Correct string comparison
const isEnabled = import.meta.env.VITE_FEATURE_ENABLED === 'true'

// ✅ Safe numeric conversion
const port = parseInt(import.meta.env.VITE_PORT || '3000', 10)

// ✅ Helper function approach
const getBooleanEnv = (value: string | undefined, defaultValue = false) => {
  if (value === undefined) return defaultValue
  return value.toLowerCase() === 'true'
}

Common Next Steps