Managing Environment Variables in React Builds Securely
AI generated
</>
{ }
React · Environment Variables · Vite · Next.js
Environment Variables in React Builds
managed securely instead of leaked into the bundle

Environment variables in React builds are processed differently than in a backend: they get embedded permanently into the bundle as soon as they carry the right prefix. Anyone who does not understand this accidentally bakes secrets into publicly readable JavaScript code, or wonders why a changed variable has no effect after a deployment.

17 min read Vite · Next.js · Docker · Zod Build Time · Runtime Config

1. Build time versus runtime: the fundamental misunderstanding

The most common mistake in dealing with environment variables in React happens because developers bring a false expectation from backend work. In Node.js or PHP an environment variable gets read at runtime, a restart of the process is enough to pick up a new value. Environment variables in React work fundamentally differently, because the entire code gets compiled into static files before delivery, long before any user visits the page.

This build time nature means that every process.env or import.meta.env reference gets replaced by the bundler with the actual value the variable held at the moment of the build. If the variable changes afterward on the server, that has no effect at all, unless a new build is executed. Anyone who treats environment variables in React like in the backend and only restarts the server will be puzzled by missing changes and search in the wrong place for hours.

For teams with multiple environments such as development, staging and production, this means concretely: every environment needs its own build, not just its own .env file at runtime. The following sections show how Vite and Next.js handle this constraint and how a flexible configuration for environment variables in React can still be achieved.

2. Vite: import.meta.env and the VITE_ prefix

Vite reads environment variables in React projects via import.meta.env, but for security reasons only replaces variables with the VITE_ prefix. This deliberate restriction prevents every system variable, such as a database password from the shell environment, from accidentally landing in the public client bundle. Only variables explicitly marked with VITE_ are considered intended for the client.

The built in variables import.meta.env.MODE, import.meta.env.DEV and import.meta.env.PROD are available without their own prefix and allow environment dependent logic directly in the code, such as disabling analytics in development mode.


// src/config.ts — reading Vite environment variables
interface AppConfig {
  apiBaseUrl: string;
  sentryDsn: string | undefined;
  isProduction: boolean;
}

export const config: AppConfig = {
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  sentryDsn: import.meta.env.VITE_SENTRY_DSN,
  isProduction: import.meta.env.PROD,
};

// Fails fast if a required variable is missing at build time
if (!config.apiBaseUrl) {
  throw new Error("VITE_API_BASE_URL is not set — check your .env file");
}

A detail that is often overlooked: import.meta.env values are always strings at build time, even if .env contains something like VITE_MAX_RETRIES=3. Anyone who needs a number has to convert explicitly with Number(). For environment variables in React projects using Vite, another rule applies: only variables that are actually referenced in the code end up in the bundle, dead references are not embedded by the bundler.

3. Next.js: NEXT_PUBLIC_ prefix and server versus client env

Next.js strictly separates server side and client side variables for environment variables in React projects. Everything without the NEXT_PUBLIC_ prefix is by default only available in server components, API routes and during the build itself, never in the browser. Only the NEXT_PUBLIC_ prefix explicitly makes a variable accessible to client components and embeds it into the JavaScript code at build time.

This separation is one of the biggest advantages of Next.js over a plain Vite setup: database credentials or third party API keys can be defined completely normally without a prefix and stay server side, as long as they are only read in server components or route handlers.


// app/api/orders/route.ts — server-only environment variable, never exposed
import { NextResponse } from "next/server";

// No NEXT_PUBLIC_ prefix — stays on the server, safe for secrets
const PAYMENT_API_KEY = process.env.PAYMENT_API_KEY;

export async function POST(request: Request) {
  if (!PAYMENT_API_KEY) {
    return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
  }

  const body = await request.json();
  const response = await fetch("https://payments.example.com/charge", {
    method: "POST",
    headers: { Authorization: `Bearer ${PAYMENT_API_KEY}` },
    body: JSON.stringify(body),
  });

  return NextResponse.json(await response.json());
}

// app/components/analytics-banner.tsx — client component, needs NEXT_PUBLIC_
"use client";
const analyticsId = process.env.NEXT_PUBLIC_ANALYTICS_ID;

A common mistake in environment variables in React projects with Next.js: destructuring process.env, that is const { API_KEY } = process.env. Next.js only replaces variables on direct static access such as process.env.API_KEY, because the replacement at build time works through static code analysis. Destructuring or dynamic keys such as process.env[key] are not recognized and return undefined at runtime.

4. Structuring .env files correctly

For environment variables in React projects a convention of several .env files has become established, loaded depending on context. .env holds default values for all environments, .env.local overrides these locally and is never checked in, .env.production and .env.development apply only to their respective mode. Vite and Next.js load these files automatically in the right order, with more specific files overriding more general ones.

The file .env.example documents every needed variable without real values and belongs in the repository, so new team members immediately see which environment variables in React setups they need to configure themselves. A clean .gitignore entry for .env.local and .env*.local prevents real credentials from accidentally reaching the repository. This structure is standard for both Vite and Next.js and can be used in both frameworks without additional libraries.

5. Secrets never in the client bundle: understanding build time embedding

The most critical security aspect of environment variables in React is that everything with a VITE_ or NEXT_PUBLIC_ prefix ends up as a string directly in the shipped JavaScript bundle and is visible to every visitor of the page via browser developer tools. An API key with full write access, accidentally prefixed with VITE_, is thereby effectively public, regardless of whether it is displayed anywhere in the interface.

Checking whether a secret has accidentally ended up in the bundle can be automated easily: after the build the dist directory is scanned for known secret patterns.


#!/usr/bin/env bash
# check-bundle-secrets.sh — fail the build if secrets leaked into the bundle
set -euo pipefail

BUNDLE_DIR="dist/assets"
PATTERNS=("sk_live_" "AKIA" "-----BEGIN PRIVATE KEY-----")

for pattern in "${PATTERNS[@]}"; do
  if grep -rl "$pattern" "$BUNDLE_DIR" > /dev/null 2>&1; then
    echo "[ERROR] Found potential secret pattern '$pattern' in client bundle" >&2
    exit 1
  fi
done

echo "[OK] No known secret patterns found in $BUNDLE_DIR"

For real secrets the same rule always applies to environment variables in React: they may only exist in an environment that never gets compiled into client code, that is in server components, API routes, edge functions or a separate backend. A React frontend may at most use secrets indirectly through a protected API, never reference them directly.

6. Runtime configuration for Docker images

A particularly practical problem with environment variables in React projects arises as soon as a Docker image needs to be reused across multiple environments such as staging and production. A build per environment contradicts the basic principle of containers that exactly the same image should run everywhere. The solution is a runtime configuration that injects values only at container start, instead of embedding them permanently at build time.

For that, an entrypoint script writes the actual environment values into a small JavaScript file at container start, loaded before the actual bundle, storing the values on window.


#!/usr/bin/env sh
# docker-entrypoint.sh — inject runtime config before serving static files
set -eu

CONFIG_FILE="/usr/share/nginx/html/env-config.js"

cat <<EOF > "$CONFIG_FILE"
window.__ENV__ = {
  API_BASE_URL: "${API_BASE_URL:-https://api.mironsoft.de}",
  FEATURE_FLAGS_URL: "${FEATURE_FLAGS_URL:-}"
};
EOF

echo "[INFO] Runtime config written for this container instance"
exec nginx -g "daemon off;"

In the React code, window.__ENV__.API_BASE_URL gets read instead of import.meta.env.VITE_API_BASE_URL, which lets a single Docker image serve both staging and production from the same build. This approach combines the advantages of build time environment variables for non sensitive configuration with runtime flexibility where the same application actually needs to run in multiple environments.

7. Type safety for environment variables with Zod

Without validation, missing or malformed environment variables in React projects lead to errors that only surface at runtime in the browser, often far removed from the actual cause. Zod allows defining a schema for all expected variables and validating it immediately when the build starts, with a clear error message instead of a cryptic undefined somewhere deep in the code.


// src/env.ts — validated, type-safe environment variables with Zod
import { z } from "zod";

const envSchema = z.object({
  VITE_API_BASE_URL: z.string().url(),
  VITE_SENTRY_DSN: z.string().url().optional(),
  VITE_MAX_UPLOAD_MB: z.coerce.number().positive().default(10),
});

// Throws a readable error immediately if something is missing or malformed
const parsed = envSchema.safeParse(import.meta.env);

if (!parsed.success) {
  console.error(parsed.error.flatten().fieldErrors);
  throw new Error("Invalid environment configuration — see console for details");
}

export const env = parsed.data;
// env.VITE_MAX_UPLOAD_MB is now a number, not a string

This approach makes environment variables in React not only type safe for TypeScript, but also self documenting: the schema itself shows which variables are expected, what format they must follow and which ones are optional. A failed build due to an invalid URL is far easier to debug than a broken feature that only surfaces in live operation.

8. CI/CD secrets management in practice

For environment variables in React projects the CI/CD pipeline itself also needs to handle secrets cleanly. GitHub Actions offers repository and environment secrets for this, stored encrypted and automatically masked in workflow logs as soon as their value would accidentally be printed. Vercel and Netlify offer their own dashboard for environment variables with separate values for preview, development and production deployments.

For Docker based deployments, secrets should never be set via ENV instructions in the Dockerfile, because they remain visible in the image layer even if the variable is overridden later. Docker secrets or a value mounted at runtime are the safer path, especially combined with the runtime configuration pattern from the previous section. This keeps environment variables in React deployments consistently secured across the entire lifecycle, from the local build all the way to production.

9. Environment variables compared across tools

The practical handling of environment variables in React projects differs noticeably between Vite, Next.js and a runtime configuration in Docker. The table below compares the key properties.

Approach Prefix Timing Suitable for secrets
Vite client variable VITE_ Build time No, public in the bundle
Next.js server variable none Server runtime Yes, stays server side
Next.js client variable NEXT_PUBLIC_ Build time No, public in the bundle
Runtime config (Docker) none needed Container start Non sensitive values only
CI/CD secret none Build process Yes, stored encrypted

The table makes clear that the question of how to handle environment variables in React correctly can never be answered in a blanket way, it always depends on the sensitivity of the value and the timing at which it is needed. Non sensitive configuration such as an API base URL can safely be embedded via VITE_ or NEXT_PUBLIC_, while real secrets must remain exclusively server side or inside the CI/CD pipeline.

Mironsoft

Secure configuration and secrets management for React apps

Are your environment variables actually configured securely?

We audit your React builds for accidentally embedded secrets, introduce type safe validation with Zod, and build a runtime configuration for your Docker images where needed.

Secrets audit

Checking existing bundles for accidentally embedded credentials

Introducing validation

Zod schemas for environment variables with clear error messages at build time

Runtime configuration

One Docker image for every environment, with clean injection at startup

10. Summary

Environment variables in React differ fundamentally from the backend, since they get permanently embedded into the shipped code at build time as soon as they carry the VITE_ or NEXT_PUBLIC_ prefix. A value changed on the server has no effect at all without a new build. This property makes clear why real secrets must never carry these prefixes, and must instead remain exclusively server side or in a separate API layer.

For multi environment deployments via Docker, a runtime configuration solves the build time problem by injecting values only at container start. Type safe validation with Zod prevents missing or malformed variables from only surfacing late in operation. Anyone applying these principles consistently avoids the most common security gaps and debugging dead ends when working with environment variables in React projects.

Environment Variables in React Builds at a Glance

Build time versus runtime

VITE_ and NEXT_PUBLIC_ variables get permanently embedded into the code. Changes only take effect after a new build.

Secrets never client side

Everything with a client prefix is publicly readable via browser developer tools. Real secrets stay server side.

Runtime configuration

An entrypoint script injects values at container start, so one Docker image suffices for multiple environments.

Type safety with Zod

A schema validates every expected variable at build time and delivers clear error messages instead of cryptic undefined values.

11. FAQ: Environment Variables in React

1Why doesn't a change take effect after redeploy?
Variables are embedded at build time. A redeploy without a new build keeps using the old values.
2Are VITE_ variables public?
Yes, every visitor can read them via browser developer tools. Never assign secrets to them.
3Why doesn't destructuring work?
Next.js only recognizes direct static access like process.env.API_KEY, not destructuring or dynamic keys.
4One image for multiple environments?
With runtime configuration: an entrypoint script injects values via window at container start instead of build time.
5What is Zod good for?
Zod validates every variable against a schema at build time and gives clear errors instead of a later undefined.
6What belongs in .env.example?
Every variable name without real values, checked in. Real .env files with values never get checked in.
7Are ENV instructions in a Dockerfile safe?
No, they remain visible in the image layer. Docker secrets or mounted files are the safer path.
8VITE_ versus NEXT_PUBLIC_?
Both mark client variables and get embedded at build time. The difference is only the framework.
9How to check for leaked secrets in the bundle?
Scan the output directory with grep for known secret patterns and fail the build on a match.
10Is a dashboard change in Vercel enough?
No, a new build has to be triggered so the changed value is actually picked up.