Environment Variables in a React Monorepo
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:
- Create
config/static
andconfig/dynamic
folders in your app or package. - Put your variables in there, with optional validation.
- Use
process.env
for runtime stuff (server). - Use
import.meta.env
for build-time stuff (client). - Use
vite-node
to run the server if you need static vars there. - Use
define
in Vite to inject your app version or other constants.