Type-Safe Environment Variables in Next.js with @t3-oss/env-nextjs and Zod (2026)
Stop shipping empty API keys. Validate Next.js env vars at build time with @t3-oss/env-nextjs and Zod 4: server/client split, Turbopack, and Docker deployments.
To make environment variables type-safe in Next.js, define a Zod (or Standard Schema) schema for your server and client variables, validate them at build time with @t3-oss/env-nextjs, and import the exported env object everywhere instead of reading process.env directly. This turns silent runtime failures (missing keys, empty strings, secrets accidentally shipped with a NEXT_PUBLIC_ prefix) into a red build that never leaves CI. I've watched a single unprefixed API key kill a Vercel promotion at 4pm on a Friday, and honestly, the fix is a 30-line file.
@t3-oss/env-nextjs 0.13.11 wraps any Standard Schema validator (Zod 4, Valibot, ArkType) and validates env vars during next build, not at first HTTP request.
You must pass runtimeEnv explicitly for NEXT_PUBLIC_ vars because Next.js replaces process.env.NEXT_PUBLIC_FOO literally at build time. Destructured or optional-chained lookups return undefined.
Zod 4 shipped z.url(), z.email(), and a rewritten parser (~3× faster on small schemas). The T3 Env docs migrated their examples in early 2026.
SKIP_ENV_VALIDATION=1 is the escape hatch for Docker builds and codegen, but it also skips .default(), so types and runtime values can drift.
Presets for Vercel, Neon, Supabase, Upstash, and Coolify remove ~40 lines of boilerplate from every new project.
For "build once, deploy many" container patterns, pair T3 Env with next-runtime-env. That's the only structural gap in the build-time model.
Why process.env in Next.js lies to you
The type of process.env.STRIPE_SECRET_KEY in a fresh Next.js 16 project is string | undefined. That's technically honest (the value could be missing) but in practice it teaches the whole team to reach for process.env.STRIPE_SECRET_KEY! with a non-null assertion, or worse, process.env.STRIPE_SECRET_KEY ?? "". Both patterns paper over a real problem: nobody actually confirmed the variable exists. I audited one of our services last quarter and found eleven non-null assertions on env vars across two dozen files. Six of them were reading variables that had been renamed six months earlier. The code compiled. It even ran. It just quietly used empty strings as API keys and blamed the vendor when things 401'd.
There's a second, worse failure mode unique to Next.js. Because the framework compiles the client bundle with build-time string replacement, a variable read as process.env.API_KEY from a React Server Component and a variable read as process.env.NEXT_PUBLIC_API_KEY from a Client Component look identical in TypeScript. Both are string | undefined. That symmetry hides the asymmetry that matters: one ends up in a serverless handler on Vercel, the other ends up in _next/static/chunks/app-*.js downloadable by anyone. The type system will not tell you which is which. A schema will. Next.js 16 also removed publicRuntimeConfig and serverRuntimeConfig, so env vars are now the only sanctioned config channel, which makes validation more important, not less.
The T3 Env approach (one file, two Zod schemas, one validated object) makes the server/client boundary a compile-time property of the code, not a naming convention we hope everyone remembers.
How does @t3-oss/env-nextjs work?
@t3-oss/env-nextjs exports a single function, createEnv, that takes three things: a server schema, a client schema, and a runtimeEnv object that maps schema keys to process.env lookups. On import, it validates the merged shape against the two schemas and either returns a typed, frozen object or throws a formatted error that stops next build immediately. Because the import happens the first time any module in your graph references env, and Next.js walks the whole graph during the build, validation runs before your production bundle is written to disk.
Under the hood the library is Standard Schema first as of the 0.12 line. It accepts any validator that implements the Standard Schema spec, including Zod 4, Valibot, ArkType, and Typia, through the same createEnv entry point. The Zod-flavoured import (@t3-oss/env-nextjs/presets-zod) still ships for people using the platform presets; sibling entrypoints /presets-valibot and /presets-arktype exist for the other validators. The library is ESM-only, so if you're on an older TypeScript config you'll need "moduleResolution": "Bundler" or newer in tsconfig.json to resolve its package.jsonexports.
The important mental model: T3 Env is not a runtime dependency for reading env vars, it's a build-time gate that produces a typed object once. After the first import, the object is cached in module scope and every subsequent import is a free property read. There is no per-request cost.
Installing t3-env with Zod 4 in Next.js 16
Assuming you already have a Next.js 16 project with the App Router, install the two dependencies. Zod 4 introduced z.url(), z.email(), and a rewritten parser that's roughly 3× faster than Zod 3 for small object schemas. Worth taking the upgrade if you're still on 3.x:
pnpm add @t3-oss/env-nextjs zod
# or
npm i @t3-oss/env-nextjs zod
Create src/env.ts. This is the file the rest of the codebase will import from:
// src/env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import * as z from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
RESEND_API_KEY: z.string().min(1),
NODE_ENV: z.enum(["development", "test", "production"]),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
},
client: {
NEXT_PUBLIC_APP_URL: z.url(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
},
// See the next section for why this block is not optional.
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
RESEND_API_KEY: process.env.RESEND_API_KEY,
NODE_ENV: process.env.NODE_ENV,
LOG_LEVEL: process.env.LOG_LEVEL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
},
emptyStringAsUndefined: true,
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});
Now import env anywhere in the codebase and you'll get autocomplete, exact string literal types for enums, and (on the client side) a runtime error if you accidentally reach for a server-only var:
// Anywhere in server code:
import { env } from "@/env";
const stripe = new Stripe(env.STRIPE_SECRET_KEY); // typed as string, no `!`
// In a Client Component:
"use client";
import { env } from "@/env";
posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY); // fine
// posthog.init(env.STRIPE_SECRET_KEY); // would throw at import time
Defining the schema: server, client, and shared vars
The server and client keys of createEnv are enforced at both type and runtime level. Entries in server must not start with NEXT_PUBLIC_, and entries in client must. If you violate either rule, the library throws before your schema even runs. This catches the single most damaging mistake in Next.js env handling: an engineer prefixing a secret with NEXT_PUBLIC_ to "make it available in a Client Component" and shipping it to production three days later.
There's also a third bucket, shared, for variables that need to exist on both sides. Typically NODE_ENV, a release version, or a Vercel-injected value like VERCEL_ENV. Anything in shared is available on the client bundle, so use it sparingly and never for secrets:
The emptyStringAsUndefined: true flag is one of the highest-value one-liners in this file. Empty strings are truthy for schema validators. z.string() passes on "", and a config file that expects a real value silently accepts nothing. Setting the flag makes createEnv treat "" as missing, which then either fails validation (good) or falls back to a schema default (also good). For numeric or boolean env vars, use Zod 4's coercion helpers because everything in process.env is a string:
For staff-eng codebases, split the schema into src/env/server.ts and src/env/client.ts. This prevents the server variable names (even without values) from ending up in the client bundle as source-mapped strings. Then re-export both from src/env/index.ts for ergonomic imports. It's a minor bundle hygiene win, but auditors love it because "what's in the client bundle?" answers itself.
The runtimeEnv trap: why destructuring fails
The most common bug report I see on the t3-env repo is "my NEXT_PUBLIC_* vars are all undefined on the client." The answer is always the same: Next.js does not pass process.env to the browser at runtime. It performs a static string replacement during compilation, so process.env.NEXT_PUBLIC_FOO in your source is literally rewritten to "the-value" in the emitted client bundle. That rewrite is lexical. It only works if the exact string process.env.NEXT_PUBLIC_FOO appears somewhere in the module graph the compiler sees.
If T3 Env destructured process.env internally, the client bundle would end up with undefined everywhere because Next.js would never see the individual property accesses. That's why runtimeEnv is required and why every key you declare in server, client, or shared must have a matching process.env.X line in runtimeEnv. The library runs a type-level check to enforce it. Miss a key and TypeScript will underline the entire object literal.
The one exception is experimental__runtimeEnv for server-only projects. It skips the lexical requirement for server vars (since Next.js 13.4.4 stopped statically analysing server-side process.env reads), but you still have to list all client and shared keys explicitly. Almost no real Next.js app can go server-only, so I generally recommend just writing the runtimeEnv block. If it feels tedious, the type errors will tell you when you've missed a key. It's mechanical, not intellectually hard.
How do you validate environment variables at build time in Next.js?
Import @/env from next.config.ts. That's the whole trick. Next.js evaluates next.config.ts before it walks your app router, so any thrown error there interrupts the build with a clean, greppable message before Turbopack does anything expensive:
This surfaces before the bundle is written. Combined with a CI job that runs next build against your staging env, you get a genuine gate: an .env.staging that has drifted from .env.example fails the pipeline, not the first real user. For teams still migrating away from Pages Router, the Pages Router to App Router migration playbook covers moving env reads out of getServerSideProps without leaking them into the client bundle.
Edge Runtime, Turbopack, and Docker deployments
Three deployment shapes need slightly different care.
Edge Runtime
The Edge Runtime supports Zod 4, Valibot, and ArkType. They're all standards-compliant JavaScript with no Node built-ins. T3 Env itself does no Node-only work, so importing your validated env from an Edge route handler or middleware is fine. What you cannot do is read variables inside a Client Component and then use them in Middleware; those are different execution contexts. Keep the schema honest: if a var is only ever read in a Vercel Edge Function, mark it as server (it's still not going to the browser). Bundle-wise, Valibot at ~1.37 KB gzipped leaves the most headroom under the 1 MB Edge Function cap; Zod 4 at ~14 KB is fine for most cases. For a deeper look at the runtime split, see the Edge Runtime vs Node Runtime performance guide.
Turbopack
Turbopack, the default bundler in Next.js 16, respects the same process.env.NEXT_PUBLIC_* string replacement contract as webpack did, so T3 Env's runtimeEnv pattern works unchanged. Two Turbopack-specific gotchas worth naming:
Optional chaining kills inlining.process?.env?.NEXT_PUBLIC_FOO works on the server (because Node evaluates it at runtime) but ships as undefined to the client. Turbopack is stricter about this than webpack was. Always write the literal process.env.NEXT_PUBLIC_FOO.
.env.local changes don't hot-reload. Turbopack's dev-mode caching is aggressive enough that editing .env.local requires a full next dev restart. HMR reloads without picking up the new value.
If you're using output: "standalone", add transpilePackages: ["@t3-oss/env-nextjs", "@t3-oss/env-core"] to next.config.ts; some Node versions warn about ESM-in-require otherwise. The Turbopack configuration guide covers the rest of the config surface.
Docker and self-hosted
Docker is where the "build-time validation" model breaks down and you need to be deliberate. If you build one image and promote it through dev, preview, and production, your next build runs in a container that has none of the real production secrets. Validation fails, image build fails.
Two options. Either build the image with SKIP_ENV_VALIDATION=1 (covered next) and rely on your orchestration layer to inject real vars at container start, or pass placeholder values during build and revalidate at boot. For NEXT_PUBLIC_* vars specifically, the second option is worse. Those get inlined into static HTML at build time, so a placeholder ships to the browser and any runtime change is invisible until the image is rebuilt. If you need per-environment NEXT_PUBLIC_* from a single image, pair T3 Env with next-runtime-env (covered in the alternatives section). The Docker self-hosting guide walks through the multi-stage build patterns this needs.
# Dockerfile fragment for a T3 Env + standalone build
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN corepack enable pnpm && pnpm i --frozen-lockfile
# NEXT_PUBLIC_* must exist at build (they get inlined into the client bundle)
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
# Skip schema validation for server secrets. Real values injected at container start.
ENV SKIP_ENV_VALIDATION=1
RUN pnpm build
Skipping validation for CI, codegen, and Docker builds
Set SKIP_ENV_VALIDATION=1 in the process environment and createEnv returns a proxy that satisfies the type signature but skips the schema. Every real read still throws (proxies can't fabricate strings you didn't provide), but the module import succeeds. This exists because there are legitimate cases for skipping (Prisma codegen, Drizzle Kit migrations run from a monorepo consumer, Docker image builds, Storybook builds) where you don't want a full .env file just to generate a type or a bundle.
Add a CI check that fails if SKIP_ENV_VALIDATION appears in any workflow file except the ones that legitimately need it (Docker builds, codegen). Otherwise it becomes the default cure for "annoying build error" and validation quietly stops running anywhere.
Standard Schema: swapping Zod for Valibot or ArkType
Since v0.12, the library accepts any validator that speaks Standard Schema. That means you can drop Zod for Valibot (smaller bundle) or ArkType (sharpest TS inference) with a one-file change:
Valibot compiles to a fraction of Zod's bundle size because you only pay for the validators you import. For env vars (a few dozen entries at most) that's not the reason to switch. The real reason is if you already use Valibot elsewhere and don't want a second validator library in the dep graph. For most projects, Zod 4 is fine: the schema runs once at boot and the resulting object is cached forever.
Presets for Vercel, Neon, Supabase, and Upstash
As of v0.13, T3 Env ships presets: pre-written schemas for the Vercel-injected vars (VERCEL_URL, VERCEL_ENV, VERCEL_REGION, etc.), the Neon Vercel integration, Supabase, Upstash Redis, Uploadthing, Coolify, Railway, Render, Netlify, Fly, and a few others. You extend them with the extends array:
import { createEnv } from "@t3-oss/env-nextjs";
import { vercel, upstashRedis } from "@t3-oss/env-nextjs/presets-zod";
import * as z from "zod";
export const env = createEnv({
extends: [vercel(), upstashRedis()],
server: {
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
},
client: {
NEXT_PUBLIC_APP_URL: z.url(),
},
runtimeEnv: {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
});
// env.VERCEL_URL, env.VERCEL_ENV, env.UPSTASH_REDIS_REST_URL are all typed & validated.
The Vercel preset saves ~15 lines of schema per project and, more importantly, encodes the correct types for things like VERCEL_ENV being "development" | "preview" | "production". Left to their own devices, most engineers type it as string and lose the exhaustiveness check in switch statements. One caveat: skipValidation does not propagate to extends, so if you rely on SKIP_ENV_VALIDATION in Docker, replicate the flag in each preset call.
Alternatives: envsafe, znv, next-runtime-env, and vanilla Zod
People land on this comparison from Google constantly, so here's the honest matrix. The short answer: on Vercel, T3 Env is the default; on self-hosted containers with per-environment client vars, pair it with next-runtime-env. Everything else is niche.
Library
Validates at build
Client/server split
Runtime injection
Standard Schema
Best fit
@t3-oss/env-nextjs
Yes (via next.config)
Yes, type-enforced
No, build-time only
Yes (Zod / Valibot / ArkType)
Vercel or standard Next.js deployments
next-runtime-env
No
Client only
Yes, injects at request time via <script>
No (pair with Zod)
Container "build once, deploy many"
envsafe
No
No
Runtime
No
Legacy, author flags as likely discontinued
znv
No
No
Runtime
No (Zod-only)
Plain Node services, not Next.js
dotenv-safe
No (presence check only)
No
Runtime
No
Legacy, unmaintained
arkenv
Yes (Vite plugin)
No
Runtime
No (ArkType)
ArkType shops with Vite
Vanilla Zod
Only if imported from next.config
Manual
Runtime
N/A
Tiny projects; you'll rebuild T3 Env anyway
The one gap in T3 Env's model, build-time inlining of NEXT_PUBLIC_*, is exactly what next-runtime-env solves. It drops a <PublicEnvScript /> tag in your root layout that reads process.env.NEXT_PUBLIC_* at request time on the server and exposes them via window.__ENV. You then call env("NEXT_PUBLIC_FOO") in Client Components instead of reading a static value. Same image ships to dev, preview, and prod; each container gets its own docker run -e NEXT_PUBLIC_APP_URL=.... Use T3 Env to validate the shape; use next-runtime-env for the per-environment subset. On Vercel, where every deploy gets its own build, you don't need it.
Common footguns that leak secrets or break prod
1. Reading env vars in static Server Components
If a Server Component reads env.SOME_VAR at render time and Next.js caches that route statically, the value is baked into the prerendered HTML. That's fine for public config, disastrous for anything even mildly sensitive. Move the read into a force-dynamic segment, an await connection() gate from next/server, or a request-scoped function.
2. Reading env vars at module top level of a shared file
Top-level const foo = env.SOMETHING in a module that's imported by both server and client crashes with an unhelpful error because the client cannot access server-only keys. Wrap the read in a function that only runs in the appropriate context, and add import "server-only" at the top of secret-reading files. Any transitive import from a Client Component then fails the build with a clean message.
3. Forgetting emptyStringAsUndefined
A CI system that sets DATABASE_URL= as a shell export defines the variable as an empty string, not undefined. Zod's z.string() passes on "". Your app then tries to connect to "" and dies. Set the flag.
4. Skipping the startsWith check on API keys
Every payment and auth provider has a distinctive prefix (sk_, pk_, whsec_, rk_live_). Encoding it in the schema costs one line and catches the day someone pastes the wrong key into .env.production. This pairs well with the pattern in the Stripe integration guide where distinct secret and webhook keys are easy to swap.
5. Hardcoding NEXTAUTH_URL for preview deployments
Preview deployments on Vercel get unique *.vercel.app URLs. Hardcoding NEXTAUTH_URL to your production domain breaks OAuth callback URLs on every preview. Derive it from VERCEL_BRANCH_URL (stable per branch, unlike VERCEL_URL which is per-deploy) via a schema transform, and add that stable per-branch host to your OAuth provider's allowlist. See the Auth.js v5 sessions and providers guide for the full flow.
6. Flag production secrets as Sensitive on Vercel
Vercel's Sensitive Environment Variables can be set, rotated, and deleted but not read from the dashboard, and their values are auto-redacted in build logs. Flag every production secret Sensitive as a matter of policy. It's a zero-cost defense-in-depth measure, and it survives dashboard credential leaks. Every variable in your server schema is a candidate.
7. Placing expensive validation on the critical path
The env module is imported at the top of every file that uses it. If validation is slow (large schema, expensive refine callbacks), that cost is paid on cold start. Keep the schema flat, avoid refine that does network work, and remember: createEnv is a build-time gate, not a per-request validator.
Frequently Asked Questions
Frequently Asked Questions
How do I validate environment variables in Next.js before building?
Define a Zod (or any Standard Schema) schema for your server and client variables inside src/env.ts using createEnv from @t3-oss/env-nextjs, import that file from next.config.ts, and always read env.X instead of process.env.X. Validation runs during next build, so missing or malformed variables fail the build instead of the first user request.
What is @t3-oss/env-nextjs and why not use process.env directly?
@t3-oss/env-nextjs is a ~5KB library that validates environment variables against a schema at build time and returns a fully-typed object. process.env.X is always typed as string | undefined, which teaches non-null assertions and hides silent failures. An empty string or renamed variable makes it to production undetected. A schema turns those into build errors.
Why is process.env undefined in Next.js client components (and how do I fix it)?
Next.js does not ship process.env to the browser. It performs a lexical string replacement on process.env.NEXT_PUBLIC_FOO during the build, so only exact literal references get replaced. Destructuring, spreading, or optional chaining all return undefined in the client bundle. This is why T3 Env requires an explicit runtimeEnv block with literal process.env.X entries.
Does @t3-oss/env-nextjs work with Turbopack and Next.js 16?
Yes. Turbopack respects the same NEXT_PUBLIC_* string replacement contract webpack did, so T3 Env's runtimeEnv pattern works unchanged. Two practical differences: HMR does not pick up new .env.local values (restart next dev), and optional chaining like process?.env?.NEXT_PUBLIC_X silently breaks inlining. Use the literal expression.
How do I skip environment variable validation for Docker builds?
Set SKIP_ENV_VALIDATION=1 in the process environment before running next build. createEnv returns a proxy that satisfies the type signature but throws on real reads, so the build completes without a full .env file. Inject real values at container start via your orchestration layer. Note that .default() is also skipped; track t3-env#266.
Can I use Valibot or ArkType instead of Zod with T3 Env?
Yes. Since v0.12, @t3-oss/env-nextjs accepts any validator that implements the Standard Schema spec, including Valibot, ArkType, and Typia. The createEnv call is identical; only the schema syntax changes. Valibot cuts bundle size significantly if you were already using it elsewhere; ArkType gives the sharpest TS inference.
Are environment variables accessible in Next.js middleware and Edge Runtime?
Yes. The Edge Runtime supports Zod, Valibot, and ArkType. Importing a validated env from Middleware or an Edge route handler works. The one Turbopack-specific bug historically was optional chaining on process?.env?.X in Edge code; write literal reads and it works. Watch bundle size: Edge Functions cap at 1 MB gzipped.
Enable Next.js Draft Mode in the App Router with draftMode(), signed __prerender_bypass cookies, and secure preview URLs for Sanity, Contentful, and DatoCMS.