{"course":{"slug":"building-apps-with-ai","title":"Building Apps with AI","url":"https://addaly.com/learn/building-apps-with-ai","level":"beginner","track":"build"},"lesson":{"slug":"environment-variables-and-the-two-worlds","title":"Environment variables, and the prefix that leaks","url":"https://addaly.com/learn/building-apps-with-ai/environment-variables-and-the-two-worlds","position":45,"of":87,"minutes":9,"answer":"A variable with a public prefix is copied into the JavaScript every visitor downloads, so a secret with that prefix is not a secret.","keyIdea":"A variable with a public prefix is copied into the JavaScript every visitor downloads, so a secret with that prefix is not a secret."},"body":{"format":"markdown","text":"## A string handed to a process\n\nAn environment variable is a named string given to a program when it starts. `process.env.DATABASE_URL` reads one. The value is not in the code and not in git, which is the entire point: the same code runs on your laptop with one database and on the host with another, and neither address is written down anywhere a stranger can read.\n\nLocally, a `.env` file is a convenience: the dev server reads it and sets the variables for you. On the host there is no `.env`; there is a settings page where you type each name and value. The earlier lesson said this once; this one is about the ways it goes wrong after you have done that.\n\n## Two kinds, and the prefix\n\nSome variables are read only on the server. Some are meant for the browser: the address of your own API, a public analytics id, a map tile key that is designed to be public.\n\nFrameworks mark the second kind with a prefix: `NEXT_PUBLIC_` in Next.js, `VITE_` in Vite, `REACT_APP_` in older React setups, `PUBLIC_` in Astro and SvelteKit. The prefix is an instruction to the build: copy this value into the JavaScript bundle. After the build, the value is a literal string inside a file every visitor downloads. Open the site, view source, search for the value, and there it is.\n\nA model asked to \"call the weather API from the page\" will reach for the prefix, because it is the quickest way to make a browser-side call work. That is a leak. A key with a public prefix belongs to whoever opens the developer tools, and providers bill the key's owner, not the person using it.\n\nThe rule is short. Secrets never get the prefix. Browser code that needs a secret calls your own server route, and the server makes the call. The block on security returns to this with a test you can run on your own bundle.\n\n## Build time versus run time\n\nPublic variables are inlined when the build runs. Change one on the host's settings page and nothing happens until you redeploy, because the old value is already baked into the files being served. This is the mechanism behind \"I changed it and it still shows the old value\".\n\nServer-side variables vary by host. Some read them fresh at each request; some capture them at build. The symptom is the same and the cure is the same: after changing any variable, trigger a deploy. Most hosts have a \"redeploy\" button that rebuilds the current commit for exactly this.\n\n> Figure: The prefix is an instruction to the build. No prefix: server only: DATABASE_URL, SESSION_SECRET, LLM_API_KEY, the OAuth client secret; Read by code that runs on your machine or your host; Never appears in anything a visitor downloads; Browser code that needs it calls your own route instead. NEXT_PUBLIC_, VITE_, PUBLIC_: Copied into the JavaScript bundle when the build runs; A literal string in a file every visitor downloads; Honest for a public analytics id or a map tile key; Changing it on the dashboard does nothing until you redeploy. A model asked to call an API from the page reaches for the prefix, because it is the quickest way to make a browser-side call work. Providers bill the key's owner, not the person using it.\n\n## `undefined` is a word\n\nWhen a variable is missing, `process.env.API_URL` is `undefined`, and JavaScript will happily put that into a string:\n\n```javascript\nfetch(`${process.env.API_URL}/bookings`)   // fetches \"undefined/bookings\"\n```\n\nThe request goes to a path that does not exist and the error mentions a 404, not a missing variable. Hours have gone into this. The defence is to fail loudly at startup, in one small file:\n\n```typescript\nconst required = [\"DATABASE_URL\", \"SESSION_SECRET\"];\nfor (const name of required) {\n  if (!process.env[name]) throw new Error(`Missing environment variable: ${name}`);\n}\n```\n\nNow a missing variable stops the app on its first request with a message that names it. Libraries like `zod` let you validate shape as well as presence (that the port is a number, that the URL starts with `postgres://`) and the same idea applies.\n\n## Three environments\n\nLocal, preview and production should hold different values, and the one that matters most is the database. A preview deployment of an unmerged branch pointed at the production database is how a migration test wipes real users' data. Hosts let you scope each variable to an environment; use it, and give previews a database of their own or a branch of the real one (Neon and similar services create database branches for exactly this).\n\n## Where the local file goes wrong\n\nNext.js reads `.env`, then `.env.local`, and later files override earlier ones. A stale value in `.env.local` beats the fresh one you just put in `.env`. A variable exported in your shell profile beats both. When a value refuses to change, ask the process what it actually sees:\n\n```bash\nnode -e \"console.log(process.env.DATABASE_URL)\"\n```\n\nAnd keep `.env.example` committed with every name and a fake value. It is the map that makes a new machine or a new collaborator take five minutes, and it is the only place the full list is written down."},"check":{"question":"An app calls a weather service directly from the browser using NEXT_PUBLIC_WEATHER_KEY, and it works. What is wrong?","options":[{"text":"Nothing; the prefix is how Next.js is designed to expose variables to the browser, and the documentation recommends it for this.","correct":false,"tempting":"The prefix does exactly that (it exposes the value) which is the problem when the value is a key billed to your account."},{"text":"The browser cannot read environment variables, so the call must be failing silently.","correct":false,"tempting":"The call works because the build copied the value into the bundle; the browser is reading a string, not an environment."},{"text":"The key is in the JavaScript sent to every visitor, so anyone can copy it and spend on your account.","correct":true},{"text":"The variable should be in .env.local rather than .env.","correct":false,"tempting":"Which file it lives in changes nothing about where the build puts it; the prefix decides that."}]},"prev":{"slug":"deploying-for-a-stranger","title":"Deploying so a stranger can open it","url":"https://addaly.com/learn/building-apps-with-ai/deploying-for-a-stranger"},"next":{"slug":"which-version-is-live","title":"Which version is live","url":"https://addaly.com/learn/building-apps-with-ai/which-version-is-live"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Environment variables, and the prefix that leaks', in Building Apps with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-apps-with-ai/environment-variables-and-the-two-worlds (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Environment variables, and the prefix that leaks","courseTitle":"Building Apps with AI","module":{"index":5,"slug":"save-points-and-shipping","title":"Save points, and shipping to a stranger"},"answer":null,"keyIdea":"A variable with a public prefix is copied into the JavaScript every visitor downloads, so a secret with that prefix is not a secret.","hero":{"kind":"figure","figure":{"t":"figure","label":"Figure","figure":{"left":{"title":"No prefix: server only","items":["DATABASE_URL, SESSION_SECRET, LLM_API_KEY, the OAuth client secret","Read by code that runs on your machine or your host","Never appears in anything a visitor downloads","Browser code that needs it calls your own route instead"]},"type":"compare","right":{"title":"NEXT_PUBLIC_, VITE_, PUBLIC_","items":["Copied into the JavaScript bundle when the build runs","A literal string in a file every visitor downloads","Honest for a public analytics id or a map tile key","Changing it on the dashboard does nothing until you redeploy"]},"title":"The prefix is an instruction to the build","caption":"A model asked to call an API from the page reaches for the prefix, because it is the quickest way to make a browser-side call work. Providers bill the key’s owner, not the person using it."},"alt":"Figure: The prefix is an instruction to the build. No prefix: server only: DATABASE_URL, SESSION_SECRET, LLM_API_KEY, the OAuth client secret; Read by code that runs on your machine or your host; Never appears in anything a visitor downloads; Browser code that needs it calls your own route instead. NEXT_PUBLIC_, VITE_, PUBLIC_: Copied into the JavaScript bundle when the build runs; A literal string in a file every visitor downloads; Honest for a public analytics id or a map tile key; Changing it on the dashboard does nothing until you redeploy. A model asked to call an API from the page reaches for the prefix, because it is the quickest way to make a browser-side call work. Providers bill the key’s owner, not the person using it."}},"blocks":[{"t":"h2","id":"a-string-handed-to-a-process","runs":[{"text":"A string handed to a process"}]},{"t":"p","runs":[{"text":"An environment variable is a named string given to a program when it starts. "},{"text":"process.env.DATABASE_URL","code":true},{"text":" reads one. The value is not in the code and not in git, which is the entire point: the same code runs on your laptop with one database and on the host with another, and neither address is written down anywhere a stranger can read."}]},{"t":"p","runs":[{"text":"Locally, a "},{"text":".env","code":true},{"text":" file is a convenience: the dev server reads it and sets the variables for you. On the host there is no "},{"text":".env","code":true},{"text":"; there is a settings page where you type each name and value. The earlier lesson said this once; this one is about the ways it goes wrong after you have done that."}]},{"t":"h2","id":"two-kinds-and-the-prefix","runs":[{"text":"Two kinds, and the prefix"}]},{"t":"p","runs":[{"text":"Some variables are read only on the server. Some are meant for the browser: the address of your own API, a public analytics id, a map tile key that is designed to be public."}]},{"t":"p","runs":[{"text":"Frameworks mark the second kind with a prefix: "},{"text":"NEXT_PUBLIC_","code":true},{"text":" in Next.js, "},{"text":"VITE_","code":true},{"text":" in Vite, "},{"text":"REACT_APP_","code":true},{"text":" in older React setups, "},{"text":"PUBLIC_","code":true},{"text":" in Astro and SvelteKit. The prefix is an instruction to the build: copy this value into the JavaScript bundle. After the build, the value is a literal string inside a file every visitor downloads. Open the site, view source, search for the value, and there it is."}]},{"t":"p","runs":[{"text":"A model asked to “call the weather API from the page” will reach for the prefix, because it is the quickest way to make a browser-side call work. That is a leak. A key with a public prefix belongs to whoever opens the developer tools, and providers bill the key’s owner, not the person using it."}]},{"t":"p","runs":[{"text":"The rule is short. Secrets never get the prefix. Browser code that needs a secret calls your own server route, and the server makes the call. The block on security returns to this with a test you can run on your own bundle."}]},{"t":"h2","id":"build-time-versus-run-time","runs":[{"text":"Build time versus run time"}]},{"t":"p","runs":[{"text":"Public variables are inlined when the build runs. Change one on the host’s settings page and nothing happens until you redeploy, because the old value is already baked into the files being served. This is the mechanism behind “I changed it and it still shows the old value”."}]},{"t":"p","runs":[{"text":"Server-side variables vary by host. Some read them fresh at each request; some capture them at build. The symptom is the same and the cure is the same: after changing any variable, trigger a deploy. Most hosts have a “redeploy” button that rebuilds the current commit for exactly this."}]},{"t":"h2","id":"undefined-is-a-word","runs":[{"text":"undefined","code":true},{"text":" is a word"}]},{"t":"p","runs":[{"text":"When a variable is missing, "},{"text":"process.env.API_URL","code":true},{"text":" is "},{"text":"undefined","code":true},{"text":", and JavaScript will happily put that into a string:"}]},{"t":"code","lang":"javascript","label":"JavaScript","text":"fetch(`${process.env.API_URL}/bookings`)   // fetches \"undefined/bookings\""},{"t":"p","runs":[{"text":"The request goes to a path that does not exist and the error mentions a 404, not a missing variable. Hours have gone into this. The defence is to fail loudly at startup, in one small file:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"const required = [\"DATABASE_URL\", \"SESSION_SECRET\"];\nfor (const name of required) {\n  if (!process.env[name]) throw new Error(`Missing environment variable: ${name}`);\n}"},{"t":"p","runs":[{"text":"Now a missing variable stops the app on its first request with a message that names it. Libraries like "},{"text":"zod","code":true},{"text":" let you validate shape as well as presence (that the port is a number, that the URL starts with "},{"text":"postgres://","code":true},{"text":") and the same idea applies."}]},{"t":"h2","id":"three-environments","runs":[{"text":"Three environments"}]},{"t":"p","runs":[{"text":"Local, preview and production should hold different values, and the one that matters most is the database. A preview deployment of an unmerged branch pointed at the production database is how a migration test wipes real users’ data. Hosts let you scope each variable to an environment; use it, and give previews a database of their own or a branch of the real one (Neon and similar services create database branches for exactly this)."}]},{"t":"h2","id":"where-the-local-file-goes-wrong","runs":[{"text":"Where the local file goes wrong"}]},{"t":"p","runs":[{"text":"Next.js reads "},{"text":".env","code":true},{"text":", then "},{"text":".env.local","code":true},{"text":", and later files override earlier ones. A stale value in "},{"text":".env.local","code":true},{"text":" beats the fresh one you just put in "},{"text":".env","code":true},{"text":". A variable exported in your shell profile beats both. When a value refuses to change, ask the process what it actually sees:"}]},{"t":"code","lang":"bash","label":"Bash","text":"node -e \"console.log(process.env.DATABASE_URL)\""},{"t":"p","runs":[{"text":"And keep "},{"text":".env.example","code":true},{"text":" committed with every name and a fake value. It is the map that makes a new machine or a new collaborator take five minutes, and it is the only place the full list is written down."}]}],"check":{"question":[{"text":"An app calls a weather service directly from the browser using NEXT_PUBLIC_WEATHER_KEY, and it works. What is wrong?"}],"options":[{"text":[{"text":"Nothing; the prefix is how Next.js is designed to expose variables to the browser, and the documentation recommends it for this."}],"correct":false,"tempting":[{"text":"The prefix does exactly that (it exposes the value) which is the problem when the value is a key billed to your account."}]},{"text":[{"text":"The browser cannot read environment variables, so the call must be failing silently."}],"correct":false,"tempting":[{"text":"The call works because the build copied the value into the bundle; the browser is reading a string, not an environment."}]},{"text":[{"text":"The key is in the JavaScript sent to every visitor, so anyone can copy it and spend on your account."}],"correct":true,"tempting":null},{"text":[{"text":"The variable should be in .env.local rather than .env."}],"correct":false,"tempting":[{"text":"Which file it lives in changes nothing about where the build puts it; the prefix decides that."}]}],"order":[3,0,2,1],"why":null},"prev":{"slug":"deploying-for-a-stranger","title":"Deploying so a stranger can open it","minutes":8},"next":{"slug":"which-version-is-live","title":"Which version is live","minutes":8}}}