Skip to main content

Environment Variables in React (Part 2) #155

📖 Introduction

In Environment Variables in React (Part 1), we covered the fundamentals of environment variables in client-side React, including their build-time injection, naming conventions (REACT_APP_ or VITE_), and usage of .env files. This second part delves into more advanced topics and practical considerations, such as managing variables in CI/CD pipelines, type checking for environment variables (especially with TypeScript), and strategies for handling runtime configuration when build-time variables are insufficient.


📚 Prerequisites

Before we begin, ensure you have:

  • Understood the concepts from Part 1 (build-time injection, .env files, security of client-side variables).
  • Basic familiarity with CI/CD (Continuous Integration/Continuous Deployment) concepts.
  • Experience with TypeScript is helpful for the type checking section but not mandatory for others.

🎯 Article Outline: What You'll Master

In this article, you will learn:

  • Environment Variables in CI/CD Pipelines: Setting variables in platforms like GitHub Actions, GitLab CI, Netlify, Vercel.
  • Precedence of Environment Variables: Shell-set vs. .env files.
  • Type Checking Environment Variables (TypeScript): Ensuring type safety for your configuration.
  • Expanding Variables in .env Files (dotenv-expand): Using variables within .env files themselves.
  • Runtime Configuration vs. Build-time Configuration: When and how to fetch configuration dynamically.
  • Best Practices and Common Pitfalls.

🧠 Section 1: Environment Variables in CI/CD Pipelines

When you deploy your React application using a CI/CD pipeline (e.g., GitHub Actions, GitLab CI, Jenkins, CircleCI) or directly through hosting platforms (Netlify, Vercel, AWS Amplify), you typically don't commit your production .env files (especially those with sensitive-sounding but public keys like REACT_APP_PROD_API_KEY) directly to the repository if the repo is public or if different environments (staging, production) use different keys managed by different teams.

Instead, these platforms provide a secure way to set environment variables for your build process:

  • Platform UI: Most CI/CD and hosting platforms offer a settings UI where you can define environment variables (key-value pairs). These are securely stored and injected into the build environment when your build script (npm run build) runs.
  • Secrets Management: For any values that are even mildly sensitive (even if they are public keys, you might want to control their exposure), platforms offer "secrets" management.

Example: Setting Environment Variables in Netlify or Vercel

  1. Go to your site settings on the platform.
  2. Find the "Environment Variables" or "Build & Deploy" -> "Environment" section.
  3. Add your variables, e.g.:
    • REACT_APP_API_URL = https://api.production.example.com
    • REACT_APP_SENTRY_DSN = your-actual-sentry-dsn-for-production
    • NODE_ENV = production (often set automatically by these platforms during build)
    • CI = true (also often set automatically)

Example: GitHub Actions Workflow

# .github/workflows/deploy.yml
name: Deploy to Production

on:
push:
branches:
- main

jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '18.x'
- name: Install dependencies
run: npm ci # or yarn install --frozen-lockfile
- name: Build application
run: npm run build
env: # Set environment variables for the build step
REACT_APP_API_URL: ${{ secrets.PROD_API_URL }}
REACT_APP_VERSION: ${{ github.sha }} # Example: use commit SHA as version
NODE_ENV: production # Explicitly set if needed
# - name: Deploy to hosting service (e.g., Netlify, Vercel CLI, S3 sync)
# run: |
# # Your deployment commands here
# # Example: netlify deploy --prod --dir=build

In this GitHub Actions example:

  • env: block within the "Build application" step sets environment variables specifically for that step.
  • REACT_APP_API_URL: ${{ secrets.PROD_API_URL }}: This uses GitHub Secrets to securely store the production API URL. You would define PROD_API_URL in your repository's Settings -> Secrets and variables -> Actions.

Key Point: Variables set directly in the CI/CD environment or hosting platform's settings will typically override any values defined in your committed .env files (like .env.production).


💻 Section 2: Precedence of Environment Variables

Understanding the order in which environment variables are loaded is crucial, especially when using .env files. While the exact behavior can vary slightly between tools (CRA, Vite, custom setups using dotenv), a general order of precedence is often:

  1. Shell Environment Variables: Variables set directly in your terminal session or by your CI/CD system (e.g., export REACT_APP_FOO=bar; npm run build). These almost always take the highest precedence.
  2. .env.local: For all environments, local overrides. (Not committed to Git).
  3. Environment-Specific Local Overrides:
    • .env.development.local (for NODE_ENV=development)
    • .env.test.local (for NODE_ENV=test)
    • .env.production.local (for NODE_ENV=production) (These are also not committed to Git).
  4. Environment-Specific Files:
    • .env.development (for NODE_ENV=development)
    • .env.test (for NODE_ENV=test)
    • .env.production (for NODE_ENV=production) (These can be committed to Git with environment defaults).
  5. .env: General default values. (Can be committed).

Create React App Specifics: CRA has a well-defined loading order for .env files. Refer to its documentation for the exact specifics, but it generally follows the pattern above. It also sets NODE_ENV automatically (development for npm start, production for npm run build, test for npm test).

Vite Specifics: Vite also loads .env files based on the current mode (which often corresponds to NODE_ENV). For example, .env.production is loaded when vite build runs (default mode 'production'). .env.development for vite dev server. Vite also supports .env.[mode].local files.

Always check the documentation for your specific build tool (CRA, Vite, Next.js, etc.) to confirm the exact loading order and behavior.


🛠️ Section 3: Type Checking Environment Variables (TypeScript)

If you're using TypeScript, you'll want type safety for your environment variables to catch typos and ensure they are of the expected type.

3.1 - Create React App with TypeScript

For CRA, you can extend the NodeJS.ProcessEnv interface by creating a type declaration file, for example, src/react-app-env.d.ts (CRA might generate a similar file, you can add to it).

// src/react-app-env.d.ts (or a new custom.d.ts file)

// Ensure this file is included in your tsconfig.json "include" array
// (e.g., "include": ["src"] or explicitly "src/custom.d.ts")

declare namespace NodeJS {
interface ProcessEnv {
// Add your REACT_APP_ variables here with their expected types
NODE_ENV: 'development' | 'production' | 'test';
PUBLIC_URL: string;
REACT_APP_API_URL: string;
REACT_APP_SENTRY_DSN?: string; // Optional variable
REACT_APP_ENABLE_FEATURE_X: 'true' | 'false'; // If you expect string booleans
}
}

Now, when you access process.env.REACT_APP_API_URL, TypeScript will know it's a string. If you try to access process.env.REACT_APP_NON_EXISTENT_VAR, TypeScript will give you an error.

3.2 - Vite with TypeScript

Vite handles environment variable typing differently through import.meta.env. You need to define the types for import.meta.env in a type declaration file, typically src/vite-env.d.ts (Vite usually creates this file).

// src/vite-env.d.ts

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

interface ImportMetaEnv {
// Add your VITE_ variables here
readonly VITE_API_URL: string;
readonly VITE_SENTRY_DSN?: string;
readonly VITE_ENABLE_FEATURE_Y: string; // Vite loads all env vars as strings initially
// Add other Vite-specific env variables if needed (like VITE_USER_NODE_ENV)
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}

Now, import.meta.env.VITE_API_URL will be correctly typed. Note that Vite loads all environment variables as strings by default. You'll need to parse them (e.g., VITE_ENABLE_FEATURE_Y === 'true' for booleans, parseInt(import.meta.env.VITE_SOME_NUMBER) for numbers) in your application code.

Parsing and Validating Environment Variables: Regardless of TypeScript, it's often a good practice to have a dedicated module (e.g., src/config.js or src/env.js) that reads, parses, validates, and exports your environment variables with sensible defaults. This centralizes configuration logic.

// src/config.ts (Example for Vite)
const config = {
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
sentryDsn: import.meta.env.VITE_SENTRY_DSN,
isFeatureYEnabled: import.meta.env.VITE_ENABLE_FEATURE_Y === 'true',
nodeEnv: import.meta.env.MODE,
// Add more parsed variables here
};

// Optional: Basic validation
if (!config.apiUrl) {
throw new Error("VITE_API_URL is not defined. Please check your .env files.");
}

export default config;

🔬 Section 4: Expanding Variables in .env Files (dotenv-expand)

Sometimes you might want to define an environment variable based on another variable within the same .env file. Standard dotenv (which powers .env loading in many tools) doesn't support this directly. However, the dotenv-expand utility can add this capability.

Example:

# .env
APP_NAME=MyAwesomeReactApp
REACT_APP_WELCOME_MESSAGE="Welcome to ${APP_NAME}!"
# Without dotenv-expand, ${APP_NAME} would be literal.
# With dotenv-expand, it would become "Welcome to MyAwesomeReactApp!"
  • Create React App: CRA does not support variable expansion in .env files out of the box. You'd need to customize its Webpack configuration (e.g., by ejecting or using a tool like craco) to add dotenv-expand. This is generally not recommended unless absolutely necessary due to increased complexity.
  • Vite: Vite does support variable expansion in .env files by default using its own .env loading mechanism, which is compatible with dotenv-expand syntax.
    # .env (for Vite)
    APP_NAME=MyViteApp
    VITE_GREETING="Hello from ${APP_NAME}"
    # In your code: import.meta.env.VITE_GREETING will be "Hello from MyViteApp"

If your tool doesn't support it, it's often simpler to manage such derived configurations in your JavaScript/TypeScript config module.


✨ Section 5: Runtime Configuration vs. Build-time

As discussed, standard client-side React apps (CRA, Vite SPAs) primarily use build-time configuration. The config is baked into the deployed static assets.

What if you need configuration that can change after the app is built and deployed, without rebuilding? This is runtime configuration.

Scenarios for Runtime Configuration:

  • A multi-tenant application where each tenant has a different theme or API endpoint, and you deploy the same frontend bundle for all tenants.
  • Feature flags that you want to toggle on/off for users without a new deployment.
  • A/B testing configurations.

Strategies for Runtime Configuration (for SPAs):

  1. Fetch a Configuration File:

    • When your application starts, it makes an API call to a well-known endpoint (e.g., /app-config.json) on your server or a CDN.
    • This JSON file contains the runtime configuration.
    • Your app then uses this configuration to set up API clients, enable/disable features, etc.
    • Pros: Flexible; config can be updated by changing the JSON file on the server/CDN.
    • Cons: Adds a small delay to app startup (for the config fetch). The app needs to handle the state before config is loaded.
    // App.js
    // useEffect(() => {
    // fetch('/app-config.json')
    // .then(res => res.json())
    // .then(config => {
    // // Initialize services with this config
    // // setAppConfig(config);
    // });
    // }, []);
  2. Inject Configuration via a Global Variable (Server-Side Templating):

    • If your index.html is served by a backend that can inject dynamic content (e.g., an Express server rendering a template), you can inject a global JavaScript variable with the configuration.
    <!-- index.html served by a backend -->
    <script>
    window.__APP_CONFIG__ = {
    apiUrl: "https://dynamic-api.example.com",
    featureX: true
    // This object is generated by the server
    };
    </script>
    • Your React app then reads window.__APP_CONFIG__.
    • Pros: Config available immediately on app load.
    • Cons: Requires your index.html to be served dynamically, not just as a static file from a CDN.
  3. Using a Feature Flagging Service:

    • Services like LaunchDarkly, Optimizely, Firebase Remote Config allow you to manage feature flags and other configurations remotely.
    • Your app integrates their SDK, fetches the latest configuration, and reacts to changes, often in real-time.
    • Pros: Very powerful for feature flagging, A/B testing, and gradual rollouts.
    • Cons: Introduces a dependency on a third-party service (and associated costs).

For most SPAs, build-time environment variables handle a majority of configuration needs. Runtime configuration is for more dynamic scenarios.


💡 Conclusion & Key Takeaways (Part 2)

Managing environment variables effectively extends beyond just .env files. Understanding how they integrate with CI/CD, ensuring type safety with TypeScript, and knowing when to opt for runtime configuration are all crucial for robust application development and deployment.

Key Takeaways:

  • CI/CD platforms provide secure ways to set build-time environment variables, often overriding local .env files.
  • TypeScript users should define types for process.env (CRA) or import.meta.env (Vite) for type safety.
  • Vite supports variable expansion in .env files; CRA typically does not without customization.
  • For configuration that needs to change post-build without a redeploy, runtime configuration strategies (fetching a config file, server injection, feature flagging services) are necessary.
  • Always be mindful of the build-time vs. runtime nature of configuration in client-side SPAs.

➡️ Next Steps

With a solid understanding of production builds and environment variables, we're well-equipped to deploy our application. The next article, "Deploying to Netlify (Part 1)", will guide you through the process of deploying your React application to a popular and developer-friendly hosting platform.

Let's get your app live!


glossary

  • CI/CD (Continuous Integration/Continuous Deployment/Delivery): Practices and tools for automating the building, testing, and deployment of software.
  • GitHub Secrets: Encrypted environment variables stored in GitHub, accessible by GitHub Actions workflows.
  • Type Declaration File (.d.ts): In TypeScript, a file used to provide type information for JavaScript code or to augment existing types (like process.env).
  • Runtime Configuration: Application settings that are fetched or determined when the application is running in the user's browser, rather than being baked in at build time.
  • Feature Flagging Service: A third-party service that allows developers to remotely enable or disable features in their application without deploying new code.

Further Reading