Skip to content

How it works ​

One request, start to finish.

The request path ​

text
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.__ENV

On 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:

js
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.freeze makes the object read-only. Assigning to window.__ENV.NEXT_PUBLIC_API_URL throws 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>:

tsx
<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 ​

SituationReadsNon-public key
Server: server components, route handlers, proxy, SSRprocess.envReturned
Browser, after PublicEnvScript or EnvScript ranwindow.__ENVThrows
Browser, no script on the page (context approach only)Nothing usefulReturns 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 approachContext approach
Server component<PublicEnvScript /> in <head><PublicEnvProvider> around children
Client readenv('NEXT_PUBLIC_X')useEnvContext().NEXT_PUBLIC_X
Works outside ReactYes, any browser codeNo, hooks only
envParsers in the browserYesNo
Before hydrationWith disableNextScriptNo
Values visible in page HTMLYes, inline scriptYes, 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.

Released under the MIT License. Forked from next-runtime-env by Expatfile.tax LLC.