How it works
One request, start to finish.
The request path
server ─▶ <PublicEnvScript /> in the root layout
├─ await connection() render this route per request
├─ read process.env keep keys starting with NEXT_PUBLIC_
└─ render inline script window.__ENV = escaped, frozen JSON
browser ─▶ script runs before hydration
└─ env('NEXT_PUBLIC_API_URL') reads window.__ENVOn the server, env() never touches window.__ENV. It reads process.env directly, so server components, route handlers, and proxy see every variable, public or not.
What lands in the HTML
For NEXT_PUBLIC_API_URL=https://api.example.com, the script sets:
window['__ENV'] = Object.freeze({ NEXT_PUBLIC_API_URL: 'https://api.example.com' })Two details matter:
- Escaping. The JSON is escaped so
<,>,&, and'become\u003c,\u003e,\u0026, and\u0027. A value like</script><script>alert(1)</script>stays a string and can't close the tag. - Freezing.
Object.freezemakes the object read-only. Assigning towindow.__ENV.NEXT_PUBLIC_API_URLthrows in strict mode code (every ES module) and is ignored elsewhere.
When the script runs
By default the script goes through next/script with strategy: 'beforeInteractive'. Next.js queues it and runs it before React hydrates, so client components always find window.__ENV populated.
Code that runs before hydration is the exception. instrumentation-client.ts (where SDKs like Sentry initialize) runs before Next.js processes beforeInteractive scripts, so env() there returns undefined. Render a plain inline script instead, which runs while the browser parses <head>:
<PublicEnvScript disableNextScript nonce={{ headerKey: 'x-nonce' }} />With disableNextScript, Next.js no longer adds its CSP nonce for you, so pass nonce yourself if you use a Content Security Policy. Script approach has the full story.
Keep the default strategy
Don't pass strategy: 'afterInteractive' in nextScriptProps. Client components hydrate before an afterInteractive script runs, so env() returns undefined during hydration and React reports a mismatch with the server HTML.
Dynamic rendering
Next.js prerenders pages at build time whenever it can. A prerendered page would capture the build machine's environment, which is exactly what next-dynenv exists to avoid.
PublicEnvScript and PublicEnvProvider call connection() before reading process.env. That tells Next.js the output depends on the incoming request, so every route under that layout renders per request.
The cost is real: routes under the layout lose static HTML and ISR. If parts of your site must stay static, give them their own root layout in a route group without the script, and don't call env() in their client components.
EnvScript and EnvProvider don't call connection(), because they render whatever object you pass them. If you build that object from process.env, call await connection() yourself. Custom variables shows the pattern.
With cacheComponents
When cacheComponents is on, Next.js rejects connection() outside a <Suspense> boundary during prerendering. Wrap the part of the root layout that renders PublicEnvScript or PublicEnvProvider in <Suspense>, and have server components that read env() call await connection() themselves so their values aren't cached at build time. The integration suite covers the default rendering model.
How env() decides
| Situation | Reads | Non-public key |
|---|---|---|
| Server: server components, route handlers, proxy, SSR | process.env | Returned |
Browser, after PublicEnvScript or EnvScript ran | window.__ENV | Throws |
| Browser, no script on the page (context approach only) | Nothing useful | Returns undefined |
The last row matters if you use the context approach: env() and envParsers don't read React Context. Client components there use useEnvContext().
Script or context
Both approaches deliver the same values from the same source. They differ in how client code reads them.
| Script approach | Context approach | |
|---|---|---|
| Server component | <PublicEnvScript /> in <head> | <PublicEnvProvider> around children |
| Client read | env('NEXT_PUBLIC_X') | useEnvContext().NEXT_PUBLIC_X |
| Works outside React | Yes, any browser code | No, hooks only |
envParsers in the browser | Yes | No |
| Before hydration | With disableNextScript | No |
| Values visible in page HTML | Yes, inline script | Yes, React Server Component payload |
Pick the script approach unless you specifically want values to flow through React. Read context approach for when that's true.