App Router: script approach
The script example renders PublicEnvScript in the root layout, reads public values with env() in a client component, and runs a strict Content Security Policy with a per-request nonce.
Files
| File | What it does |
|---|---|
src/app/layout.tsx | Renders PublicEnvScript in <head>, with the nonce read from the x-nonce header |
src/proxy.ts | Generates a nonce per request, sets the CSP response header and the x-nonce request header |
src/instrumentation.ts | Calls makeEnvPublic('BAZ') at server start, so BAZ is also served as NEXT_PUBLIC_BAZ |
src/app/client-side/page.tsx | Client component that reads NEXT_PUBLIC_FOO and NEXT_PUBLIC_BAZ with env() |
src/app/server-side/page.tsx | Server component that awaits connection() and reads BAR and BAZ with env() |
src/app/api/bar-baz/route.ts | Route handler that returns BAR and BAZ as JSON |
next.config.ts | Default config; nothing next-dynenv specific |
The layout
// src/app/layout.tsx
import { PublicEnvScript } from 'next-dynenv'
import type { ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
{/* x-nonce is set per request by src/proxy.ts */}
<PublicEnvScript nonce={{ headerKey: 'x-nonce' }} />
</head>
<body>{children}</body>
</html>
)
}PublicEnvScript collects every NEXT_PUBLIC_* variable at request time and writes a frozen window.__ENV before the app hydrates. Because it calls connection(), every route under this layout renders per request.
Reading values in a client component
// src/app/client-side/page.tsx
'use client'
import { env } from 'next-dynenv'
export default function ClientSide() {
return (
<dl>
<dt>NEXT_PUBLIC_FOO</dt>
<dd>{env('NEXT_PUBLIC_FOO') ?? 'unset'}</dd>
<dt>NEXT_PUBLIC_BAZ</dt>
<dd>{env('NEXT_PUBLIC_BAZ') ?? 'unset'}</dd>
</dl>
)
}During server rendering env() reads process.env; in the browser it reads window.__ENV. Both sides see the same values, so hydration matches. Keep client components to NEXT_PUBLIC_ keys: env('BAR') here would return the real value during server rendering, writing it into the HTML, and then throw in the browser.
The nonce flow
src/proxy.tsruns before each page request. It creates a random nonce, builds a CSP withscript-src 'self' 'nonce-…' 'strict-dynamic'(plus'unsafe-eval'in development only), sets that policy on the response, and forwards the nonce to the app as anx-noncerequest header.PublicEnvScriptgets{ headerKey: 'x-nonce' }as itsnonceprop, reads the header withheaders(), and puts the nonce on its script.- Next.js reads the nonce from the
Content-Security-Policyheader and applies it to its own scripts.
The proxy matcher skips route handlers, static assets, and prefetch requests. On Next.js 15, the same code lives in middleware.ts with an exported middleware function.
Run it
From the repository root:
pnpm install
pnpm build
cp examples/with-app-router-script/.env.example examples/with-app-router-script/.env.local
pnpm --filter example-app-router-script devThen open:
http://localhost:3000/client-sidefor the client componenthttp://localhost:3000/server-sidefor the server componenthttp://localhost:3000/api/bar-bazfor the route handler
Things to try
Build once, start twice. Build without any values, then start the same build with different ones:
pnpm --filter example-app-router-script build
NEXT_PUBLIC_FOO=first BAR=secret BAZ=shared pnpm --filter example-app-router-script start
NEXT_PUBLIC_FOO=second BAR=rotated BAZ=moved pnpm --filter example-app-router-script startWatch makeEnvPublic work. Start with BAZ set: /client-side shows its value as NEXT_PUBLIC_BAZ, and the server log prints a line ending in (next-dynenv) when the variable is copied.
Look at what the browser gets. Run window.__ENV in the devtools console, or view the page source and search for __ENV. You will find NEXT_PUBLIC_FOO and NEXT_PUBLIC_BAZ, and never BAR.
Check the CSP. The response headers carry a fresh nonce on every load, and the same nonce appears on the scripts in the page source.