Nuxt runtimeConfig: Why Env Vars Are Undefined in Production
In April a deploy went out with the API base URL still pointing at localhost. Locally everything was green. The staging container had API_BASE_URL set correctly in its dashboard. The app kept calling localhost for two days, and the only person who noticed was a customer on a call with sales.
The variable was there the whole time. The name was the problem.
Nuxt has two things that both look like environment variables and run at completely different moments. Once that split clicks, this family of bugs stops happening.
Two files, two jobs
Your .env file belongs to the build. The Nuxt CLI loads it during nuxt dev, nuxt build and nuxt generate, so its values reach process.env while your config and modules are evaluated. When you start the built server, that file is not read at all.
Your runtimeConfig belongs to the process. You declare it in nuxt.config.ts, it gets serialized into the build, and it is patched at startup from environment variables that match a naming convention.
export default defineNuxtConfig({
runtimeConfig: {
apiSecret: '', // server only
public: {
apiBase: '/api', // also shipped to the browser
},
},
})
Both keys start empty on purpose. Defaults live in the repo, real values come from the environment.
Rule one: the override name has to match
Only uppercase variables starting with NUXT_ can replace a runtime config value. Underscores separate the path, and the key name switches to screaming snake case.
NUXT_API_SECRET=... # runtimeConfig.apiSecret
NUXT_PUBLIC_API_BASE=https://api.example.com # runtimeConfig.public.apiBase
My bug was one line of the config file:
runtimeConfig: {
public: {
apiBase: process.env.API_BASE_URL,
},
}
That line runs while nuxt.config.ts is evaluated, which is build time. I built that image on my laptop, my .env was sitting right there, so API_BASE_URL resolved to http://localhost:3000 and that string got compiled into the output. The dashboard variable named API_BASE_URL could never fix it, because by the time the server boots, the only names Nuxt listens for are the NUXT_ ones.
There is a second half to that rule: a variable also has to be declared in nuxt.config.ts to be picked up at all. Nuxt matches the environment against keys that already exist in your runtime config, which keeps the whole process environment from leaking into your app code.
Rule two: destr will quietly change your types
Environment values arrive as strings, and Nuxt runs them through destr, so NUXT_MY_VAR=4848e0 comes back as the number 4848. Numbers, booleans, null and JSON arrays all get converted.
That bit me with a version string. NUXT_PUBLIC_APP_VERSION=1.10 came back as the number 1.1, the comparison against the version in our update manifest stopped matching, and a "new version available" banner stayed on screen for a week after people had already updated.
When a value has to stay a string, put literal double quotes inside it:
NUXT_PUBLIC_APP_VERSION='"1.10"'
# the quotes are part of the value, so the shell must not strip them
NUXT_PUBLIC_APP_VERSION='"1.10"' node .output/server/index.mjs
Leading zeros are the other one. A code or token like 0012345 arrives as 12345. You find out when a lookup fails on a value that looks identical in the logs.
Reading it back has its own traps
On the client, useRuntimeConfig() exposes the public namespace and Nuxt's internal app namespace, nothing else. Everything you declared at the top level is undefined in the browser. On the server the full object is available, but it is read-only, so anything you assign to it mid-request will not stick.
Put a private key in a component and you get an interesting failure: the server renders the real value, the client renders undefined, and you have handed yourself a hydration mismatch on top of the leak.
Server routes are the one place worth being pedantic. Pass the event:
export default defineEventHandler((event) => {
const { apiSecret } = useRuntimeConfig(event)
})
The argument is optional in the type signature, which is why people skip it, but passing it is what gets that route patched from environment variables at runtime. Smaller trap in the same area: the app namespace is reserved for baseURL and cdnURL, and the docs ask you not to add keys to it. Park your own values somewhere else.
One more: anything under public is serialized into every page payload. If a value would be embarrassing in view-source, it does not belong in that namespace.
How I check it now
I keep a small server route that dumps the resolved config with values masked, and I hit it against the real build output, not the dev server:
npm run build
NODE_ENV=production NUXT_PUBLIC_API_BASE=https://api.example.com \
node .output/server/index.mjs
curl -s localhost:3000/api/_config | jq
Running the built output with production variables is the part that matters. nuxt dev loads .env and will happily hide the mistake. nuxt preview loads it too, which is handy for a smoke test and misleading as a final answer.
Two smaller things. Removing a variable from .env does not unset it, so restart a stale dev server when a value refuses to disappear. And on a fully prerendered site, runtime config is locked in at prerender time, which is the case where appConfig is the better home for anything that only changes per deploy.
The model that finally made this stick for me: .env is a build-time convenience, NUXT_ variables are the runtime contract, and nuxt.config.ts is the list of keys allowed to cross it. Typing the config interfaces by hand helps as well, since a misspelled key then fails at compile time instead of at 2am.
The config-dump route and those interfaces are the two things I retype in every project, so they live in Snippet Ark. If you are still chasing render-side surprises, useFetch versus $fetch covers the version of this that shows up as a duplicate request instead of an undefined string.