Frontend Environment Variables
Reading process.env directly in components is risky for a few reasons:
- Missing values fail silently. The bundler replaces
process.env.REACT_APP_*with its value at build time. If a variable isn’t set, you getundefinedand the build still passes. You find out when a user hits the broken page. - Everything is a string.
REACT_APP_ENABLE_CHAT=falsegives you"false", which is truthy. - Reads end up scattered. Nothing lists what the app actually needs, and tests have to change
process.envto try different values.
Instead, validate the environment once with Zod and pass the result down through context.
Validate Once
// src/config.ts
import { z } from "zod";
const schema = z.object({
REACT_APP_GRAPHQL_URL: z.string().url(),
REACT_APP_ENABLE_CHAT: z.enum(["true", "false"]).default("false"),
});
export type Config = {
graphqlUrl: string;
chatEnabled: boolean;
};
export function parseConfig(env: Record<string, string | undefined>): Config {
const parsed = schema.parse(env);
return {
graphqlUrl: parsed.REACT_APP_GRAPHQL_URL,
chatEnabled: parsed.REACT_APP_ENABLE_CHAT === "true",
};
}
A missing or invalid value throws an error that names the variable. The rest of the app gets real types, like a boolean for the chat flag.
Provide It With Context
// src/config-context.tsx
import { createContext, useContext } from "react";
import type { Config } from "./config";
export const ConfigContext = createContext<Config | null>(null);
export function useConfig() {
const config = useContext(ConfigContext);
if (!config) throw new Error("useConfig needs a ConfigContext provider");
return config;
}
// src/index.tsx
const config = parseConfig({
REACT_APP_GRAPHQL_URL: process.env.REACT_APP_GRAPHQL_URL,
REACT_APP_ENABLE_CHAT: process.env.REACT_APP_ENABLE_CHAT,
});
createRoot(document.getElementById("root")!).render(
<ConfigContext.Provider value={config}>
<App />
</ConfigContext.Provider>,
);
List each variable by name. The bundler only replaces direct references, so passing process.env as a whole won’t work in the browser.
Components then call useConfig():
const { chatEnabled } = useConfig();
return chatEnabled ? <ChatWidget /> : null;
In tests, wrap the component in a provider with whatever config you need.
To catch a missing variable before deploying, call parseConfig(process.env) from a small script in CI before the build.
Block New process.env Reads
Use the n/no-process-env rule from eslint-plugin-n, and turn it off only where the config is read:
// eslint.config.js
import n from "eslint-plugin-n";
export default [
{
plugins: { n },
rules: { "n/no-process-env": "error" },
},
{
files: ["src/index.tsx"],
rules: { "n/no-process-env": "off" },
},
];
Credit
Based on Massimiliano Mirra’s “The process.env frontend time bomb (plus: a sustainable definition of ‘fixed’)”.