How to Securely Store API Keys in Vite + React with .env.local

Every modern web application eventually needs to communicate with external services. Whether it's fetching weather data, integrating with payment gateways, or connecting to AI models like Gemini, those integrations require API keys. The question isn't if you'll need them—it's how you'll protect them.

I've seen too many developers hardcode API keys directly into their source code, commit them to GitHub, and then wonder why their cloud bill suddenly skyrockets. This guide will show you the professional approach to handling secrets in Vite + React applications.

The Problem with Exposed API Keys

When you embed an API key directly in your JavaScript bundle, you're not just exposing it to your users—you're publishing it to the entire internet. Modern bundlers create minified, but still readable, source maps. Browser developer tools make extraction trivial. Malicious actors run automated scrapers that hunt for API keys in public repositories.

The consequences range from annoying to catastrophic:

  • Rate limit exhaustion: Someone else uses your quota
  • Data exfiltration: Attackers access your service data
  • Financial liability: Unexpected API charges
  • Reputational damage: Compromised user data

Understanding the Vite Environment Variable System

Vite uses a dotenv-based system that's intuitive but has specific rules you must understand. Unlike Create React App's REACT_APP_ prefix, Vite requires VITE_ for client-side variables.

Here's the critical distinction:

# .env — loaded in all modes, committed to repo
VITE_APP_NAME=MyApp

# .env.local — loaded in all modes, NOT committed (local overrides)
VITE_API_URL=https://api.example.com

# .env.development — loaded only in dev mode
VITE_DEBUG=true

# .env.production — loaded only in production builds
VITE_ANALYTICS_ID=UA-XXXXX

Only variables prefixed with VITE_ are exposed to your client-side code. Variables without this prefix remain server-side only—which is exactly what you want for truly secret values.

Setting Up Your Project Structure

A well-organized project makes security easier to maintain. Here's the structure I recommend:

my-vite-app/
├── src/
│   ├── components/
│   ├── services/
│   │   └── api.js          # Centralized API configuration
│   └── utils/
│       └── env.js          # Environment validation
├── .env                    # Public defaults (committed)
├── .env.local              # Local secrets (gitignored)
├── .env.example            # Template for new developers
├── .env.production         # Production overrides
└── .gitignore              # MUST exclude .env.local

Step 1: Configure .gitignore

This is non-negotiable. Before you create any environment files, ensure your .gitignore properly excludes sensitive files:

# Environment variables
.env.local
.env.*.local

# But keep these for reference
!.env
!.env.example
!.env.production

The !.env exception allows public, non-sensitive defaults to be committed. The .env.local pattern catches all local override files.

Step 2: Create the .env.example Template

This file serves as documentation and onboarding for new team members. It shows which variables are needed without exposing real values:

# Copy this file to .env.local and fill in your values

# Gemini API Key (get yours at https://ai.google.dev/)
VITE_GEMINI_API_KEY=your_api_key_here

# API Base URL
VITE_API_BASE_URL=https://generativelanguage.googleapis.com

# Feature Flags
VITE_ENABLE_ANALYTICS=false
VITE_DEBUG_MODE=true

Step 3: Build a Centralized API Service

Rather than scattering import.meta.env.VITE_... throughout your components, create a dedicated service layer. This centralizes configuration, enables validation, and makes future migrations painless.

// src/services/api.js
import { validateEnv } from '../utils/env';

// Validate environment on module load
const env = validateEnv([
  'VITE_GEMINI_API_KEY',
  'VITE_API_BASE_URL'
]);

class GeminiService {
  constructor() {
    this.apiKey = env.VITE_GEMINI_API_KEY;
    this.baseUrl = env.VITE_API_BASE_URL;
    this.model = 'gemini-pro';
  }

  async generateContent(prompt) {
    const response = await fetch(
      `${this.baseUrl}/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          contents: [{ parts: [{ text: prompt }] }]
        })
      }
    );

    if (!response.ok) {
      throw new Error(`API Error: ${response.status}`);
    }

    return response.json();
  }

  // Method to check if API is configured
  isConfigured() {
    return Boolean(this.apiKey && this.apiKey !== 'your_api_key_here');
  }
}

export const geminiService = new GeminiService();

Step 4: Add Environment Validation

Fail fast, fail loudly. Don't let your app limp along with missing configuration. This utility validates required variables at startup:

// src/utils/env.js
/**
 * Validates that required environment variables are present.
 * Throws descriptive errors in development, warns in production.
 */
export function validateEnv(requiredVars) {
  const missing = requiredVars.filter(
    key => !import.meta.env[key] || import.meta.env[key] === 'your_api_key_here'
  );

  if (missing.length > 0) {
    const message = `Missing environment variables: ${missing.join(', ')}\n\n` +
      `Please check your .env.local file. See .env.example for required variables.`;

    if (import.meta.env.DEV) {
      throw new Error(message);
    } else {
      console.warn(message);
    }
  }

  return import.meta.env;
}

/**
 * Safe getter that returns null for undefined variables
 * rather than exposing undefined throughout the app.
 */
export function getEnv(key, defaultValue = null) {
  return import.meta.env[key] || defaultValue;
}

Step 5: Using in React Components

With the service layer in place, your components stay clean and focused on UI logic:

// src/components/AIAssistant.jsx
import { useState } from 'react';
import { geminiService } from '../services/api';

export function AIAssistant() {
  const [response, setResponse] = useState('');
  const [loading, setLoading] = useState(false);

  // Check if properly configured
  if (!geminiService.isConfigured()) {
    return (
      
⚠️ API not configured. Please set your VITE_GEMINI_API_KEY in .env.local.
); } const handleSubmit = async (prompt) => { setLoading(true); try { const result = await geminiService.generateContent(prompt); setResponse(result.candidates[0].content.parts[0].text); } catch (error) { console.error('Generation failed:', error); setResponse('Error: ' + error.message); } finally { setLoading(false); } }; return (
{loading ? : }
); }

Advanced: Proxying API Requests

For production applications, avoid exposing API keys to the client entirely. Use Vite's dev server proxy to route requests through your backend:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api/gemini': {
        target: 'https://generativelanguage.googleapis.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api\/gemini/, '/v1beta'),
        configure: (proxy) => {
          proxy.on('proxyReq', (proxyReq) => {
            // Add API key server-side, never expose to client
            proxyReq.setHeader('X-Goog-Api-Key', process.env.GEMINI_API_KEY);
          });
        }
      }
    }
  }
});

With this setup, your React code calls /api/gemini/... and the API key is injected server-side by the Vite dev server. In production, you'd replace this with your actual backend proxy.

Production Deployment Checklist

Before shipping to production, verify every item:

  • .env.local is in .gitignore
  • No API keys in commit history (git log --all --grep='key')
  • .env.example is up to date
  • Environment validation runs on startup
  • Production API keys are rotated and scoped
  • Rate limiting is configured on the service
  • Monitoring alerts for unusual API usage

The Bottom Line

Security isn't a feature you bolt on at the end—it's a practice you integrate from day one. The .env.local pattern in Vite gives you a clean, maintainable way to manage secrets across development and production environments.

Remember: environment variables prefixed with VITE_ will end up in your client bundle. For truly sensitive operations, always proxy through a backend you control. The extra complexity is insignificant compared to the cost of a compromised API key.