DEV Community

Sohana Akbar
Sohana Akbar

Posted on

Build-Time vs Runtime Environment Variables in Vite and Next.js: The Hidden Trap That Catches Everyone

Picture this: you've built a beautiful web app, shipped it to production, and everything works perfectly. Then you realize—you need to change your API URL for staging. Simple, right? Just update the .env file and restart.

Wrong. Your frontend is still pointing to the old URL, and you're staring at a broken app wondering what went wrong. I've been there, and it's not fun.

The culprit? A fundamental misunderstanding of when environment variables get read in your build process. Let's fix that.

The Core Distinction
Environment variables in frontend frameworks fall into two categories, and confusing them will either leak your secrets to the browser or lock you into rebuild hell:

Build-time variables are injected into your JavaScript bundle during npm run build. They become static strings burned into your output files. Change them? You'll need a new build and deploy .

Runtime variables are read by server-side code at request time. They're never shipped to the browser, and changing them takes effect on the next request—no rebuild required .

The Prefix Convention Trap
Both Vite and Next.js use a prefix convention to decide what gets exposed to the browser. And this is where most developers slip up.

Vite: The VITE_ Rule
Vite only exposes variables prefixed with VITE_ to your client-side code. Everything else stays server-side only .

javascript
// .env
VITE_API_URL=https://api.example.com
DB_PASSWORD=supersecret

// In your code
console.log(import.meta.env.VITE_API_URL) // "https://api.example.com" ✅
console.log(import.meta.env.DB_PASSWORD) // undefined ❌
The catch? Once you build your Vite app, these VITE_ variables are hardcoded into the bundle. That VITE_API_URL is now a permanent string in your JavaScript files .

Next.js: The NEXT_PUBLIC_ Rule
Next.js follows a similar pattern with NEXT_PUBLIC_. Variables without this prefix are only available on the server .

javascript
// .env
NEXT_PUBLIC_ANALYTICS_ID=abc123
DATABASE_URL=postgres://localhost:5432/myapp

// In your component
process.env.NEXT_PUBLIC_ANALYTICS_ID // "abc123" ✅ Available client-side
process.env.DATABASE_URL // undefined in browser ❌
The Big Problem: Single Docker Image, Multiple Environments
Here's where this gets really painful. Modern deployment practices favor building once and promoting the same artifact through multiple environments (staging, QA, production).

But with VITE_ and NEXT_PUBLIC_ variables, you can't do this. They're baked in at build time . If you build with staging values and promote that same build to production, you're still pointing to staging.

This is why the official Next.js docs explicitly warn: "We do not recommend using the runtimeConfig option, as this does not work with the standalone output mode" .

The Server-Side Escape Hatch
Next.js offers a way out if you're using the App Router. By reading environment variables in server components, you can access runtime values :

typescript
import { connection } from 'next/server'

export default async function Component() {
await connection() // Opt into dynamic rendering
const value = process.env.MY_VALUE // Evaluated at runtime!
// ...
}
This approach lets you use a single Docker image across multiple environments. The value is read when the request comes in, not when the app was built.

Beyond the Prefix: Workarounds for Runtime Variables
The vite-envs Plugin
A clever plugin called vite-envs flips the script entirely. Instead of baking variables at build time, it generates a script that injects environment variables when the container starts .

The Dockerfile becomes:

dockerfile
ENTRYPOINT sh -c "./vite-envs.sh && nginx -g 'daemon off;'"
Now your Vite app can respond to environment variables at runtime, just like a backend would .

The HTML Injection Pattern
Another approach involves generating a preprocessed.js file that exposes environment variables on window.process.env and loading it before your main bundle .

html

This pattern works across frameworks and gives you complete control over what gets exposed.

Security: The Elephant in the Room
Here's the rule you can't break: Never put secrets in build-time variables.

Build-time variables are inlined into your JavaScript bundle. Anyone can open DevTools and read them. VITE_ and NEXT_PUBLIC_ variables are visible to anyone who inspects your site .

Database passwords, API secrets, admin keys—these belong on the server, period.

Which Approach Should You Choose?
Use build-time variables for:

Public API keys

Public analytics IDs

Feature flags that don't change per environment

Anything you'd be comfortable sharing publicly

Use runtime variables (server-side) for:

Database credentials

Private API tokens

Dynamic values that differ across environments

Configuration you want to change without rebuilding

The Bottom Line
The build-time vs runtime distinction isn't just theoretical—it's the difference between smooth deployments and "why is my staging URL in production?" panic.

If you're building a single Docker image to promote across environments, you need to think carefully about how you handle environment variables. The default prefix-based approach won't work for runtime configuration.

Plan your architecture around when variables are read, not just how they're named. Your future self—and your team—will thank you.

What's your approach to environment variables in frontend builds? Have you run into the single-image, multiple-environments problem? Let's discuss in the comments.

Top comments (0)