Next.js 10,514 loads .env* files into process.env with no dotenv 20,543 dependency of your own. Five sources are consulted in order and the first hit wins: process.env, .env.$(NODE_ENV).local, .env.local, .env.$(NODE_ENV), .env. NODE_ENV is development under next dev and production otherwise; the only other value allowed is test, and in it .env.local is skipped so everyone's tests agree. Keep the files in the project root even when your code lives in src/; $NAME expands to another variable:
NEXT_PUBLIC_SITE_NAME=Widget Shop
API_BASE=https://api.example.com
DB_PASSWORD=hunter2-from-dotenv
SUPPORT_URL=$API_BASE/supportThe prefix is the whole security model. A NEXT_PUBLIC_* variable is inlined at build time: every textual occurrence of it in code that reaches the browser is replaced by the literal string. Anything else stays on the server.

Test it once so you trust it. Put the site name in a Client Component, run next build, then grep what shipped: grep -rl "Widget Shop" .next/static matched a chunk, while grep -rl "hunter2-from-dotenv" .next/static matched nothing. The replacement is literal:
0,function(){return(0,t.jsxs)("p",{children:["Welcome to ","Widget Shop"]})}])}]);A Server Component printing SUPPORT_URL rendered https://api.example.com/support, so $API_BASE expanded as documented. Because the substitution is textual, process.env[varName] and a destructured process.env are not inlined; they arrive as undefined.
Inlining also freezes the value into the artifact, so one Docker 514 image promoted from staging to production carries staging's values forever. Read anything that must vary per environment on the server during dynamic rendering — await connection() from next/server opts the component out of static rendering, so process.env.MY_VALUE is read per request — and pass it down as a prop.