Runtime environment variables for Next.js, validated with any Standard Schema library — zod, valibot, arktype and the rest — and grouped into isolated spaces.
- values are read from
process.envat runtime, never inlined at build time - one schema per key, one type —
get("APP_NAME")is fully typed - several spaces per app: ship one to the browser, keep another server-only
- a guard that fails loudly when a value would be baked in at build time
NEXT_PUBLIC_* variables are inlined into the bundle by next build, so one image serves
one environment, and a value that changes means a rebuild. Libraries that validate env at
build time — t3-env among them — inherit that for the client side; the
ones that ship values at runtime tend to hand back an untyped string | undefined. This
package does both: runtime values, each parsed by the schema you declared.
npm i next-env-spaceNeeds Next.js 16.3 or later and React 19.2 or later, both peer dependencies. The package is ESM only. The schema library is yours to pick — every example below uses zod, but any Standard Schema implementation works the same way.
import * as z from "zod";
import { createEnvSpace } from "next-env-space";
export const publicEnv = createEnvSpace(
{
APP_NAME: z.string(),
APP_VERSION: z.string().optional(),
REQUEST_TIMEOUT_SECONDS: z.coerce.number(),
FEATURE_ENABLED: z.stringbool({
truthy: ["TRUE"],
falsy: ["FALSE"],
}),
},
{ name: "public" },
);The schema is always a shape with one schema per key — keys from different libraries may
even share one shape. A single object schema around the keys — z.object({ ... }),
v.object({ ... }) — does not type-check: Standard Schema does not expose the keys an
object declares, and every variable is parsed on its own so a bad one can name itself.
Every value arrives as string | undefined, so the schema is where it turns into something
else — coercion, a default, a boolean, a parsed JSON document:
export const publicEnv = createEnvSpace(
{
PORT: z.coerce.number().default(3000),
DEBUG: z.stringbool().default(false),
SERVICE_URLS: z.preprocess(
(raw) => JSON.parse(raw as string) as unknown,
z.record(z.string(), z.url()),
),
},
{ name: "public" },
);
publicEnv.get("SERVICE_URLS"); // Record<string, string>Schemas have to validate synchronously: the values are parsed on the spot, so a key with an async refinement fails on its first read.
Two publishers ship a space to the browser. Whichever one you render, everything in that space becomes public — keep secrets in a separate space that is never rendered.
Render ClientEnvScript once per space, in a layout above the client components that
read it — above, not merely before them: a layout that a client-side navigation mounts has
no document left to write a script into, so there the values travel with
ClientEnvScript's own render. It serialises the raw values into a <script> tag that
runs before any client module is evaluated, so it serves every read — get() at module
scope included.
// app/layout.tsx
import { ClientEnvScript } from "next-env-space/server";
import { publicEnv } from "@/env";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ClientEnvScript space={publicEnv} />
{children}
</body>
</html>
);
}The values are shipped in an inline <script>, so a policy like script-src 'nonce-...'
blocks it and the space never reaches the browser. Pass the nonce to ClientEnvScript and it
goes on the tag:
import { headers } from "next/headers";
const nonce = (await headers()).get("x-nonce") ?? undefined;
<ClientEnvScript space={publicEnv} nonce={nonce} />;Do not reach for 'unsafe-inline' to make the script run.
A per-request nonce needs every script of the page to carry it, and that does
not combine with cacheComponents: the static shell is built before any
request, so Next bakes its own bootstrap scripts into it without a nonce and
the browser blocks them — a
Next limitation,
not something ClientEnvScript can route around. The env script itself streams
with the nonce of the request either way. Under cacheComponents, prefer a
hash-based policy or ClientEnvProvider.
ClientEnvProvider publishes the same space through React context, wrapping the tree
rather than sitting next to it. Nothing is written into the document, so there is no nonce
to pass and no script-src to widen — but context is only readable while a component
renders, so in the browser the only reads it serves are
getAsync() / getAllAsync() called during a client component render
(the table below has the full picture). Nothing changes on the
server, and rendering both publishers is fine: the script answers first and the context is
left unread.
// app/layout.tsx
import { ClientEnvProvider } from "next-env-space/server";
<body>
<ClientEnvProvider space={publicEnv}>{children}</ClientEnvProvider>
</body>;import { publicEnv } from "@/env";
// module scope, client components, route handlers, anywhere outside a render
const appName = publicEnv.get("APP_NAME"); // string
const timeout = publicEnv.get("REQUEST_TIMEOUT_SECONDS"); // numberInside a Server Component the value would be baked into the build output, so get throws
while next build prerenders the route — and, in development, while Cache Components runs
the prerender that stands in for the build. Every render the running server does — a
dynamic request, an ISR revalidation, a runtime prefetch, a static shell it regenerates —
reads that server's environment, and there get answers the runtime value. Use getAsync
all the same — it opts the render out of prerendering first, so the same code survives the
route turning static:
import { publicEnv } from "@/env";
export default async function Page() {
const appName = await publicEnv.getAsync("APP_NAME");
return <h1>{appName}</h1>;
}In a client component the same call works with use():
"use client";
import { use } from "react";
import { publicEnv } from "@/env";
export function AppName() {
return <h1>{use(publicEnv.getAsync("APP_NAME"))}</h1>;
}In the table, the script is <ClientEnvScript /> and the provider is <ClientEnvProvider />.
| where | get() |
getAsync() |
|---|---|---|
Server Component render, generateMetadata |
❌ throws at build time | ✅ works, opts the route out of prerendering |
| Client Component render | ⚙️ works with the script | ⚙️ works with the provider or the script, unwrapped with use() |
| Client Component, outside the render (handler, effect) | ⚙️ works with the script | ⚙️ works with the script |
| module scope of a client module | ⚙️ works with the script | ⚙️ works with the script |
| module scope of a server module | ✅ works | ✅ works |
| Route Handler | ✅ works | ✅ works |
Route Handler with dynamic = "force-static" |
❌ throws at build time | ❌ throws at build time |
| Server Action | ✅ works | ✅ works |
proxy.ts (middleware) |
✅ works | ✅ works |
instrumentation.ts — register() |
✅ works | ✅ works |
instrumentation-client.ts |
⚙️ works with the script | ⚙️ works with the script |
generateStaticParams |
❌ throws: it only runs at build | ❌ throws: it only runs at build |
inside a "use cache" function |
❌ throws at build time | ❌ throws at build time |
A module that Next imports lazily inside a render — a dynamic import() in a Server
Component, or a page module that an older Next only reached while resolving metadata for a
prefetch — runs its module scope inside that render, where React cannot tell a module-scope
read from one in a component body. That is why the guard rejects a read only where the
build output is being written: on the running server the module-scope read goes through
with the runtime value, during next build it still throws. A module the build has to
evaluate is better imported statically.
Three more places let the build capture a value without any render to opt out of, and both
reads throw there, naming the place. generateStaticParams only ever runs at build, so its
reads could only see the build machine — compute the params without them. A cached function
— "use cache", unstable_cache() — and a force-static Route Handler run at build to
fill the cache or the static response, and whatever they read is served long after: read the
value outside and pass it in as an argument, or make the route dynamic — await connection()
before the read. The same cached function or Route Handler run by the server — a cache miss,
an ISR revalidation — reads the runtime value and is left alone, like every render the
running server does.
A read at module scope of instrumentation-client.ts runs before Next boots the page, so a
throw there stops the boot and hides every error Next would otherwise show, this one included:
a blank page and a console message are all that is left. On a page that does not publish the
space that is what happens — catch the read there, or read where the value is used. On the
error document Next serves when a server render fails, get() and getAll() answer
undefined instead and report the missing space once the page has booted, so that Next gets
to show the failure that caused it; getAsync() rejects as usual.
With cacheComponents on, getAsync, <ClientEnvScript /> and <ClientEnvProvider /> all become
dynamic holes and need a <Suspense> boundary around them:
<body>
<Suspense fallback={null}>
<ClientEnvScript space={publicEnv} />
{children}
</Suspense>
</body>Put that boundary above everything that reads the space, not tightly around
ClientEnvScript — the readers have to be inside it too. Whatever stays outside belongs to
the static shell and is rendered during next build, where a read still answers, with the
build machine's value: it then sits in the prerendered HTML and mismatches the runtime value
on hydration.
getAsync is the way out of that, in a client component as much as in a Server one — the
value is produced per request and the build fails if no boundary encloses it. What has no
such escape is the synchronous get() and anything read at module scope: both answer during
the prerender, and neither guard sees it. Reading at module scope is fine in itself — the
module is evaluated again in the server process, so it holds the runtime value — it is
rendering that value into the static shell that captures it.
Every space needs its own name — it is the key the values are published under on the
client. Spaces are independent: each has its own schema, its own cache and its own
ClientEnvScript, so a second public space is rendered next to the first:
<ClientEnvScript space={publicEnv} />
<ClientEnvScript space={featureEnv} /><ClientEnvProvider /> nests instead of repeating, and merges with the provider above it:
<ClientEnvProvider space={publicEnv}>
<ClientEnvProvider space={featureEnv}>{children}</ClientEnvProvider>
</ClientEnvProvider>A space is parsed on its first read, so a misconfigured variable surfaces when the first request happens to need it. Read every space once as the server starts and a bad deployment dies right there:
// instrumentation.ts
import { publicEnv } from "@/env";
import { serverEnv } from "@/env.server";
export function register() {
publicEnv.getAll();
serverEnv.getAll();
}register() runs outside any request; getAllAsync() answers there too, with the same
values — there is just nothing to await, so the synchronous read says it straighter.
Nothing in the package needs the real values at next build: the publishers and getAsync
opt out of prerendering, so a Dockerfile can build the app without a single variable set
and let docker run -e or the orchestrator supply them to next start. The exception is a
read at module scope of a module the build evaluates: it sees whatever the build machine
has, and a required key that is missing there fails the build, which is the right call — the
value would have been baked in otherwise. Every other read the build reaches — a Server
Component, generateStaticParams, a cached function, a prerendered Route Handler — throws
right there instead of capturing. output: "standalone" changes nothing, node server.js
reads the same process.env.
A space reads process.env when a module first touches it and caches the result. In a unit
test, set the variables first and import the module under test afterwards —
vi.resetModules() between cases gives every set of values a fresh space. Under a
browser-like environment (jsdom, happy-dom) window exists, so the space looks for the
values <ClientEnvScript /> publishes and finds none: run the tests of server-side code in
the node environment.
schema is a shape with one Standard Schema per key
({ FOO: z.string() }), from any library that implements the spec.
| option | default | meaning |
|---|---|---|
name |
"default" |
unique key of the space on the client |
The returned space exposes:
get(key)/getAll()— synchronous readsgetAsync(key)/getAllAsync()— asynchronous reads, the only ones that work inside a Server Component render; in a client component, safe to unwrap withuse()name,keys,schema
Where each read works maps both onto every calling context.
The parsed values of a space as one read-only object type, for the places that carry the whole environment around rather than one key:
import type { InferEnv } from "next-env-space";
type PublicEnv = InferEnv<typeof publicEnv>;
type Timeout = PublicEnv["REQUEST_TIMEOUT_SECONDS"]; // numberThe shape itself works as well — InferEnv<typeof publicEnv.schema> names the same type.
<ClientEnvScript space={space} />— publishes one space in an inline<script>; takes an optionalnoncefor CSP<ClientEnvProvider space={space}>{children}</ClientEnvProvider>— publishes it through React context instead
This entry point is marked server-only; importing it from a client component fails the
build.
- The whole space is parsed on first read and cached for the lifetime of the process, so a bad value fails fast rather than at the call site that happens to need it — and the error names every bad value at once, not one per restart.
- A key the space does not declare throws in
get()and rejects ingetAsync()rather than reading asundefined. - Two spaces under one
nameoverwrite each other on the client. That is harmless while they declare the same keys — a hot reload re-creates a space this way — and an error as soon as they do not: a warning in development, a thrown error in production. ClientEnvScriptparses the space on the server before serialising it, so a missing or malformed value fails the render there rather than in the browser at the first read. The raw values still reach the browser, ahead of the failure, so a read there fails on the same message instead of reporting the space missing.- Do not use the
NEXT_PUBLIC_prefix: those are inlined at build time, which is exactly what this package avoids.