Environment Config — API URLs Across Dev, Staging and Production

A React application needs different configuration values depending on where it runs: the API URL in development points to http://localhost:8000 (or uses the Vite proxy at /api), in staging it points to a staging server, and in production to the production API. Hardcoding these values forces code changes between deployments. Vite’s .env file system provides a clean solution: each environment has its own file, values are injected at build time via import.meta.env, and sensitive values never reach the browser.

Environment Files

# .env.development  — loaded by: npm run dev
VITE_API_BASE_URL=http://localhost:8000/api
VITE_APP_TITLE=Blog (Dev)
VITE_ENABLE_DEVTOOLS=true

# .env.staging  — loaded by: vite build --mode staging
VITE_API_BASE_URL=https://api-staging.blog.example.com/api
VITE_APP_TITLE=Blog (Staging)
VITE_ENABLE_DEVTOOLS=false

# .env.production  — loaded by: vite build  (default production build)
VITE_API_BASE_URL=/api
VITE_APP_TITLE=Blog
VITE_ENABLE_DEVTOOLS=false

# .env.local  — loaded in all modes, NOT committed to git
# Put secrets and local overrides here
VITE_SENTRY_DSN=https://your-sentry-dsn

# .gitignore should include:
# .env.local
# .env.*.local
Note: Only variables prefixed with VITE_ are exposed to the browser. Plain environment variables (without the VITE_ prefix) are available in Vite’s Node.js build process but not in the compiled JavaScript that runs in the browser. This is a security feature — it prevents accidentally leaking server-side secrets (database passwords, private API keys) into the client bundle. Any variable you want in the browser code must start with VITE_.
Tip: Create a centralised src/config.js that reads all env variables in one place and exports them with sensible fallbacks: export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api". Every other file imports from @/config rather than reading import.meta.env directly. This makes it trivial to find all configuration usage and provides a single place to add validation (throw if a required variable is missing).
Warning: Everything in import.meta.env with the VITE_ prefix is literally embedded in the compiled JavaScript bundle — anyone can view the source and read these values. Never put secrets (private API keys, payment processor secret keys, database URLs) in VITE_ variables. Only put values that are intentionally public: API base URLs, feature flags, app names, public keys (Stripe publishable key, Sentry DSN for the frontend).

Config Module

// src/config.js — centralised configuration
const required = (key) => {
    const value = import.meta.env[key];
    if (!value) {
        throw new Error(`Missing required environment variable: ${key}`);
    }
    return value;
};

export const config = {
    // API
    apiBaseUrl:    import.meta.env.VITE_API_BASE_URL ?? "/api",

    // App
    appTitle:      import.meta.env.VITE_APP_TITLE ?? "Blog",
    isDev:         import.meta.env.DEV,           // Vite built-in: true in dev
    isProd:        import.meta.env.PROD,          // Vite built-in: true in prod
    mode:          import.meta.env.MODE,          // "development" | "production" | "staging"

    // Features
    enableDevtools: import.meta.env.VITE_ENABLE_DEVTOOLS === "true",

    // Optional services (only throw if actually needed at runtime)
    sentryDsn:     import.meta.env.VITE_SENTRY_DSN ?? null,
};

// Usage:
// import { config } from "@/config";
// const api = axios.create({ baseURL: config.apiBaseUrl });

Updating the RTK Query API Service

// src/store/apiSlice.js — use config instead of hardcoded URL
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { config } from "@/config";

export const blogApi = createApi({
    reducerPath: "blogApi",
    baseQuery: fetchBaseQuery({
        baseUrl: config.apiBaseUrl,   // ← reads from .env
        prepareHeaders: (headers, { getState }) => {
            const token = getState().auth?.accessToken
                ?? localStorage.getItem("access_token");
            if (token) headers.set("Authorization", `Bearer ${token}`);
            return headers;
        },
    }),
    // ... endpoints
});

Production Build with Different Mode

# Standard production build (uses .env.production)
npm run build

# Staging build (uses .env.staging)
vite build --mode staging

# Preview production build locally
npm run preview

# Inspect what env vars are in the bundle (never put secrets here!)
vite build && grep -r "VITE_" dist/

Common Mistakes

Mistake 1 — Forgetting VITE_ prefix (variable is undefined)

❌ Wrong — variable not exposed to browser:

API_URL=http://localhost:8000   # no VITE_ prefix
import.meta.env.API_URL   // undefined!

✅ Correct — prefix all browser-accessible variables:

VITE_API_BASE_URL=http://localhost:8000   # ✓

Mistake 2 — Committing .env.local to git

❌ Wrong — real API keys or passwords in version control:

✅ Correct — add .env.local and .env.*.local to .gitignore. Commit only .env.development and .env.production with non-secret defaults.

Quick Reference

File When Loaded Committed to Git
.env All modes Yes (non-secret defaults)
.env.development npm run dev Yes
.env.production vite build Yes
.env.local All modes (overrides) No (add to .gitignore)
.env.development.local Dev only (overrides) No

🧠 Test Yourself

You add STRIPE_SECRET_KEY=sk_live_... to .env.production for use in your frontend payment code. What is the security problem?