Webpack doesn’t just bundle your code; it can also inject environment-specific configurations directly into your application at build time.

Let’s see this in action. Imagine you have a simple React app and you want to serve different API endpoints based on whether you’re building for development or production.

// src/App.js
import React, { useState, useEffect } from 'react';

function App() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const apiUrl = process.env.NODE_ENV === 'production'
      ? 'https://api.production.com/data'
      : 'http://localhost:3001/data'; // Development API

    fetch(apiUrl)
      .then(response => {
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.json();
      })
      .then(data => setData(data))
      .catch(error => setError(error))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>Environment Variable Demo</h1>
      <p>API Endpoint Used: {process.env.NODE_ENV === 'production' ? 'https://api.production.com/data' : 'http://localhost:3001/data'}</p>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

export default App;

Now, how do we get process.env.NODE_ENV to be set correctly by Webpack? This is where webpack.DefinePlugin comes in. It’s a Webpack plugin that creates global constants which can be configured at compile time.

Here’s a typical webpack.config.js setup:

// webpack.config.js
const webpack = require('webpack');
const path = require('path');

module.exports = {
  mode: 'development', // Or 'production'
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.API_URL': JSON.stringify('http://localhost:3001'), // Example custom variable
    }),
  ],
  // ... other configurations like module rules, devServer, etc.
};

When Webpack processes this configuration, it looks for process.env.NODE_ENV. If it finds it set in your shell environment (e.g., NODE_ENV=production webpack), it uses that value. Otherwise, it defaults to 'development'. The JSON.stringify is crucial because DefinePlugin replaces the string 'process.env.NODE_ENV' with the actual string value you provide. Without it, you’d be trying to inject a JavaScript variable reference, not a literal string.

The NODE_ENV variable is special because many libraries (like React and Vue) and build tools automatically detect and use it to enable/disable certain optimizations or features. For example, React in production mode uses different, more optimized components.

You can define any environment variable this way, not just NODE_ENV. In the example above, I’ve also added process.env.API_URL. You would then use it in your code like so:

// src/api.js
const API_BASE_URL = process.env.API_URL || 'http://localhost:3000'; // Fallback for safety

export const fetchData = async () => {
  const response = await fetch(`${API_BASE_URL}/items`);
  if (!response.ok) {
    throw new Error('Failed to fetch items');
  }
  return response.json();
};

The actual magic happens because DefinePlugin performs a simple text replacement before other loaders process your code. It’s like a super-powered find-and-replace that operates at the module level. When Webpack sees process.env.NODE_ENV in your source code, it replaces it with the string value you provided in the plugin, e.g., "development" or "production". This means the variable is baked into the JavaScript bundle itself.

This has a significant implication: these variables are not dynamic at runtime. They are fixed at build time. If you change an environment variable in your shell after you’ve built your application, those changes won’t be reflected in the deployed code. You need to rebuild.

A common pattern is to use tools like dotenv to load environment variables from a .env file into your shell before running the Webpack build command.

# .env
NODE_ENV=production
API_URL=https://api.mycompany.com

Then, in your package.json scripts:

// package.json
{
  "scripts": {
    "build": "dotenv -e .env.production webpack --mode production",
    "dev": "dotenv -e .env.development webpack serve --mode development"
  }
}

Here, dotenv -e .env.production loads variables from .env.production into the environment for that specific command. Webpack then picks them up. You can also use webpack-merge to combine configurations for different environments, making your webpack.config.js cleaner.

The most surprising thing about DefinePlugin is that it doesn’t just inject strings; it can inject any JavaScript literal. If you wanted to inject a boolean or an object, you’d do JSON.stringify(true) or JSON.stringify({ key: 'value' }). This allows you to conditionally include or exclude entire code blocks based on the environment, as Webpack’s tree-shaking can often remove unused code.

If you forget to JSON.stringify a value, Webpack will try to interpret it as a global variable name, leading to errors like Uncaught ReferenceError: development is not defined.

Want structured learning?

Take the full Webpack course →