Environment Variables in a React Monorepo

by Dennis — 5 minutes

If you’ve ever worked on a React app that runs both in the browser and on a Node.js server (like one using React Router on the server), you’ve probably had to deal with environment variables.

And let’s be honest — it’s not always clear where they’re coming from, when they’re available, or how to safely use them.

A few weeks ago, we made a small change in our monorepo that made a big difference. We decided to split our environment variables into two groups:

  • Static variables: set at build time, mostly for the browser
  • Dynamic variables: set at runtime, used by the server

This idea came from how SvelteKit handles it — and now that we’ve tried it, we’re big fans.

Here’s everything you need to know to use this in your own project — whether you’re just learning or already deep into fullstack React apps.

First Things First: What’s the Difference?

Think of environment variables as settings or secrets for your app. But not all settings behave the same way.

We split them up like this:

Type Accessed via Available when? Used for...
Static import.meta.env Build time Frontend config, feature flags
Dynamic process.env Runtime (Node.js) Server code, CLI tools, sensitive data

Static = Build Time

These variables are baked into the code when you run vite build. You use them with import.meta.env. They’re great for stuff like:

  • Public API URLs
  • Feature flags
  • CDN paths
  • Version numbers

⚠️ We choose to prefix them with PUBLIC_ to make them available in the browser — that’s a safety feature.

Dynamic = Runtime

These are available when your code is running in Node.js, like when the server starts or a script runs. You access them with process.env.

They’re perfect for:

  • Secrets (like API tokens)
  • Config that changes per environment (e.g. dev vs prod)
  • Backend logic in loaders or routes

How We Organize Them

We created a little structure that makes it super clear which is which. Each package in our monorepo has a config folder like this:

config/
├── dynamic/
│   └── index.server.ts
└── static/
    └── index.ts

Then, we expose those files in package.json like this:

"exports": {
  "./config/dynamic": "./config/dynamic/index.server.ts",
  "./config/static": "./config/static/index.ts"
}

That way, other packages can do something like:

import { API_TOKEN } from "@my-app/config/dynamic";
import { PUBLIC_CDN_URL } from "@my-app/config/static";

Simple. Clean. Predictable.

Real Examples

Dynamic example (process.env)

Here’s how we actually use them:

```ts import { invariant } from "./utils/validation";

export const API_TOKEN = invariant( process.env.API_TOKEN, "Missing API_TOKEN in process.env" );

export const API_ENDPOINT = process.env.API_ENDPOINT ?? "https://api.example.com"; ```

We validate important variables early, so we catch issues fast during dev or deployment.

Static example (import.meta.env)

export const CDN_URL = import.meta.env.PUBLIC_CDN_URL;

This one can be used safely in the browser — as long as you remember that PUBLIC_ prefix.

Why Bother Separating Them?

Honestly, it just makes things easier to reason about.

Here’s what we gained:

  • No accidental leaks of secrets to the browser
  • We can build once, then run the same code in staging, production, etc. (thanks to dynamic vars)
  • Unused static variables get removed from the bundle (tree-shaking)
  • Easier mental model for anyone joining the team

A Few Things to Watch Out For

  • import.meta.env only works in files that go through Vite — so not in Node scripts
  • Never put secrets in static variables
  • Validate dynamic variables so your app fails fast if something’s missing
  • Don’t try to use both for the same value — pick one based on where it’s needed

How We Run the Server with Static Variables

We hit an issue: we wanted to use static variables (like import.meta.env.VERSION) inside our custom server (server.ts). But we used to run that file with:

ts-node server.ts

That didn’t work — import.meta.env just came back as undefined.

So we switched to vite-node, a tool that runs Node code with Vite’s features baked in.

Now we run:

vite-node server.ts

Boom 💥 — static variables now work in the server too.

Bonus: Adding the App Version from package.json

Here’s a nice trick: if you want to expose your app’s version number (like 1.3.2) to the browser, you can add it like this in your vite.config.ts:

```ts import pkg from "./package.json";

export default defineConfig({ define: { "import.meta.env.VERSION": JSON.stringify(pkg.version), }, }); ```

Then in your app:

console.log(import.meta.env.VERSION); // "1.3.2"

Helpful for debugging or showing build info in your footer or admin page.

TL;DR

If you’re in a monorepo (or even a single app), splitting static and dynamic env vars gives you:

  • 💡 Clarity on where things live
  • ✅ Safer usage of secrets
  • ⚙️ Better support for CI/CD and Docker
  • 🧼 Cleaner frontend bundles

Try It Out

Here’s what we recommend:

  1. Create config/static and config/dynamic folders in your app or package.
  2. Put your variables in there, with optional validation.
  3. Use process.env for runtime stuff (server).
  4. Use import.meta.env for build-time stuff (client).
  5. Use vite-node to run the server if you need static vars there.
  6. Use define in Vite to inject your app version or other constants.

meerdivotion

Cases

Blogs

Event