[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-nuxt-runtimeconfig-env-vars-undefined-production":3},{"content":4,"lastModified":5},"\u003Cfigure>\n  \u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1517180102446-f3ece451e9d8?auto=format&fit=crop&w=1200&q=80\" alt=\"Browser DevTools Elements panel showing a rendered component tree during an SSR debugging session\" loading=\"lazy\" \u002F>\n\u003C\u002Ffigure>\n\n\u003Cp>In April a deploy went out with the API base URL still pointing at localhost. Locally everything was green. The staging container had \u003Ccode>API_BASE_URL\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>The variable was there the whole time. The name was the problem.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>Two files, two jobs\u003C\u002Fh2>\n\n\u003Cp>Your \u003Ccode>.env\u003C\u002Fcode> file belongs to the build. The Nuxt CLI loads it during \u003Ccode>nuxt dev\u003C\u002Fcode>, \u003Ccode>nuxt build\u003C\u002Fcode> and \u003Ccode>nuxt generate\u003C\u002Fcode>, so its values reach \u003Ccode>process.env\u003C\u002Fcode> while your config and modules are evaluated. When you start the built server, that file \u003Ca href=\"https:\u002F\u002Fnuxt.com\u002Fdocs\u002F4.x\u002Fdirectory-structure\u002Fenv\" rel=\"noopener noreferrer\" target=\"_blank\">is not read at all\u003C\u002Fa>.\u003C\u002Fp>\n\n\u003Cp>Your \u003Ccode>runtimeConfig\u003C\u002Fcode> belongs to the process. You declare it in \u003Ccode>nuxt.config.ts\u003C\u002Fcode>, it gets serialized into the build, and it is patched at startup from environment variables that match a naming convention.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">export default defineNuxtConfig({\n  runtimeConfig: {\n    apiSecret: '', \u002F\u002F server only\n    public: {\n      apiBase: '\u002Fapi', \u002F\u002F also shipped to the browser\n    },\n  },\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Both keys start empty on purpose. Defaults live in the repo, real values come from the environment.\u003C\u002Fp>\n\n\u003Ch2>Rule one: the override name has to match\u003C\u002Fh2>\n\n\u003Cp>Only uppercase variables starting with \u003Ccode>NUXT_\u003C\u002Fcode> can replace a runtime config value. Underscores separate the path, and the key name switches to screaming snake case.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">NUXT_API_SECRET=...                            # runtimeConfig.apiSecret\nNUXT_PUBLIC_API_BASE=https:\u002F\u002Fapi.example.com   # runtimeConfig.public.apiBase\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>My bug was one line of the config file:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">runtimeConfig: {\n  public: {\n    apiBase: process.env.API_BASE_URL,\n  },\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>That line runs while \u003Ccode>nuxt.config.ts\u003C\u002Fcode> is evaluated, which is build time. I built that image on my laptop, my \u003Ccode>.env\u003C\u002Fcode> was sitting right there, so \u003Ccode>API_BASE_URL\u003C\u002Fcode> resolved to \u003Ccode>http:\u002F\u002Flocalhost:3000\u003C\u002Fcode> and that string got compiled into the output. The dashboard variable named \u003Ccode>API_BASE_URL\u003C\u002Fcode> could never fix it, because by the time the server boots, the only names Nuxt listens for are the \u003Ccode>NUXT_\u003C\u002Fcode> ones.\u003C\u002Fp>\n\n\u003Cp>There is a second half to that rule: a variable also has to be declared in \u003Ccode>nuxt.config.ts\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>Rule two: destr will quietly change your types\u003C\u002Fh2>\n\n\u003Cp>Environment values arrive as strings, and Nuxt runs them through \u003Ccode>destr\u003C\u002Fcode>, so \u003Ccode>NUXT_MY_VAR=4848e0\u003C\u002Fcode> comes back as the number \u003Ccode>4848\u003C\u002Fcode>. Numbers, booleans, null and JSON arrays all get converted.\u003C\u002Fp>\n\n\u003Cp>That bit me with a version string. \u003Ccode>NUXT_PUBLIC_APP_VERSION=1.10\u003C\u002Fcode> came back as the number \u003Ccode>1.1\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cp>When a value has to stay a string, put literal double quotes inside it:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">NUXT_PUBLIC_APP_VERSION='\"1.10\"'\n\n# the quotes are part of the value, so the shell must not strip them\nNUXT_PUBLIC_APP_VERSION='\"1.10\"' node .output\u002Fserver\u002Findex.mjs\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Leading zeros are the other one. A code or token like \u003Ccode>0012345\u003C\u002Fcode> arrives as \u003Ccode>12345\u003C\u002Fcode>. You find out when a lookup fails on a value that looks identical in the logs.\u003C\u002Fp>\n\n\u003Ch2>Reading it back has its own traps\u003C\u002Fh2>\n\n\u003Cp>On the client, \u003Ccode>useRuntimeConfig()\u003C\u002Fcode> exposes the \u003Ccode>public\u003C\u002Fcode> namespace and Nuxt's internal \u003Ccode>app\u003C\u002Fcode> namespace, nothing else. Everything you declared at the top level is \u003Ccode>undefined\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>Put a private key in a component and you get an interesting failure: the server renders the real value, the client renders \u003Ccode>undefined\u003C\u002Fcode>, and you have handed yourself a \u003Ca href=\"\u002Fposts\u002Fnuxt-hydration-mismatch-find-the-cause\u002F\">hydration mismatch\u003C\u002Fa> on top of the leak.\u003C\u002Fp>\n\n\u003Cp>Server routes are the one place worth being pedantic. Pass the event:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">export default defineEventHandler((event) =&gt; {\n  const { apiSecret } = useRuntimeConfig(event)\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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 \u003Ccode>app\u003C\u002Fcode> namespace is reserved for \u003Ccode>baseURL\u003C\u002Fcode> and \u003Ccode>cdnURL\u003C\u002Fcode>, and the docs ask you not to add keys to it. Park your own values somewhere else.\u003C\u002Fp>\n\n\u003Cp>One more: anything under \u003Ccode>public\u003C\u002Fcode> is serialized into every page payload. If a value would be embarrassing in view-source, it does not belong in that namespace.\u003C\u002Fp>\n\n\u003Ch2>How I check it now\u003C\u002Fh2>\n\n\u003Cp>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:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">npm run build\nNODE_ENV=production NUXT_PUBLIC_API_BASE=https:\u002F\u002Fapi.example.com \\\n  node .output\u002Fserver\u002Findex.mjs\n\ncurl -s localhost:3000\u002Fapi\u002F_config | jq\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Running the built output with production variables is the part that matters. \u003Ccode>nuxt dev\u003C\u002Fcode> loads \u003Ccode>.env\u003C\u002Fcode> and will happily hide the mistake. \u003Ccode>nuxt preview\u003C\u002Fcode> loads it too, which is handy for a smoke test and misleading as a final answer.\u003C\u002Fp>\n\n\u003Cp>Two smaller things. Removing a variable from \u003Ccode>.env\u003C\u002Fcode> 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 \u003Ccode>appConfig\u003C\u002Fcode> is the better home for anything that only changes per deploy.\u003C\u002Fp>\n\n\u003Cp>The model that finally made this stick for me: \u003Ccode>.env\u003C\u002Fcode> is a build-time convenience, \u003Ccode>NUXT_\u003C\u002Fcode> variables are the runtime contract, and \u003Ccode>nuxt.config.ts\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>The config-dump route and those interfaces are the two things I retype in every project, so they live in \u003Ca href=\"\u002Fsnippetark\u002F\" rel=\"noopener noreferrer\" target=\"_blank\">Snippet Ark\u003C\u002Fa>. If you are still chasing render-side surprises, \u003Ca href=\"\u002Fposts\u002Fnuxt-usefetch-vs-fetch-double-fetch-trap\u002F\">useFetch versus $fetch\u003C\u002Fa> covers the version of this that shows up as a duplicate request instead of an undefined string.\u003C\u002Fp>\n","2026-09-19",1789991458759]