Skip to content
Chapter 81Lesson 7

The env schema as single source of truth

A security-baseline audit of the @t3-oss/env-nextjs schema that validates and types every environment variable your app reads.

When this app first held a real secret, you put env.ts in charge: one file where every configuration value is validated and typed, once. It has since absorbed variables for auth, email, Stripe, Upstash, and observability. A dozen variables later, the senior question is whether env.ts is still the single source of truth, or whether the discipline has eroded one bypass at a time.

The previous lesson enforced one rule: no process.env.X outside env.ts. This lesson checks whether the validation behind that rule still holds, leaving you with an audit you can run against any repo.

Each invariant below protects one of four jobs env.ts has taken on as the app grew:

  1. The runtime gate. createEnv runs the schema the moment the module loads. A missing or malformed variable fails next build and names the variable, rather than booting a half-configured app.
  2. The type boundary. env.DATABASE_URL is typed string, never string | undefined, so nothing downstream null-checks it.
  3. The secret/client firewall. The server block is walled off from the browser bundle. Import a server variable into a Client Component and the build fails.
  4. The documentation. The file is the exhaustive answer to what this app needs to run.

env.ts

one source

Runtime gate

fails the build, names the var

Type boundary

env.X is string, never undefined

Secret / client firewall

server vars can't reach the browser

Documentation

what this app needs to run

env.ts and the four jobs that have collapsed into it.

Invariant 1: every access goes through the typed env

Section titled “Invariant 1: every access goes through the typed env”

Lock this one in first; the other three build on it. Every read of configuration is the typed import: import { env } from '@/env', then env.WHATEVER. A raw process.env.WHATEVER anywhere else is the warning sign.

The schema only guards what’s in it, so a variable read through raw process.env can’t be split into server or client, documented, or validated.

The two versions below read the same Stripe secret, but only the second is one the schema can see.

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

Skips the boundary. The value types as string | undefined; the ! papers over that, so a missing variable sails past the build unvalidated and surfaces as undefined on the first request, a 500, and a 3am outage hiding in one handler.

The check is a single grep for process.env across the repo; every hit outside env.ts is a finding.

Two exceptions are sanctioned: process.env.NODE_ENV, which Next.js validates itself and you’ll need for build-time branching twice later in this lesson, and framework internals reading their own settings, like a next.config.ts checking process.env.ANALYZE to toggle the bundle analyzer. Everything else routes through env.

Invariant 2: the server/client split is a firewall, not a label

Section titled “Invariant 2: the server/client split is a firewall, not a label”

The schema’s server and client blocks aren’t tidy organization; they’re a firewall. Server-only variables go in server. Anything the browser may see goes in client and must carry the NEXT_PUBLIC_ prefix, the only prefix Next.js inlines into the browser bundle. You can cross this line two ways, and they are not equally serious.

The dangerous one is a leak: a server variable imported into a Client Component. That file is bundled for the browser, so the secret would be inlined into JavaScript anyone can read. @t3-oss/env-nextjs throws at build time when you do this, so the leak can’t ship at all instead of relying on a reviewer to catch it.

A second belt does the same job: import 'server-only' atop any module that touches a secret, such as the database client, an SDK built with a private key, or the email sender.

src/lib/db.ts
import 'server-only'; // build error if this module is ever imported client-side

If a client module imports a file beginning with that line, the build breaks before the env package even fires. Either belt turns a leaked import into a failed build rather than a production breach.

The other crossing is harmless: a client variable read inside a server file. The value is already public, so this only signals confusion, worth a glance in review but not alarm.

A NEXT_PUBLIC_* name is a public promise. Because the prefix ships the value to every visitor, the name vows it is safe for the world to read. The trap is judging a variable by which vendor issued it rather than the authority it grants. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY and NEXT_PUBLIC_SENTRY_DSN are public by design: a publishable key and a DSN only identify, granting no power. But NEXT_PUBLIC_STRIPE_SECRET_KEY contradicts itself: the prefix promises “public” while the name says “secret,” and the prefix wins, shipping your key to the world.

Sort the variables by hand, since this is exactly where it’s easy to slip. Decide where each belongs in env.ts, and watch for the two that don’t belong in the schema at all.

Sort each variable into where it belongs in `env.ts`, and spot the two that don't belong in the schema at all. Drag each item into the bucket it belongs to, then press Check.

server block Server-only — grants real authority
client block (NEXT_PUBLIC_) Public by design — identifies, grants nothing
Not in the schema Doesn't belong in env.ts at all
DATABASE_URL
STRIPE_SECRET_KEY
BETTER_AUTH_SECRET
RESEND_API_KEY
UPSTASH_REDIS_REST_TOKEN
INVITATION_SIGNING_SECRET
NEXT_PUBLIC_APP_URL
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
NEXT_PUBLIC_SENTRY_DSN
NEXT_PUBLIC_POSTHOG_KEY
NODE_ENV
A feature-flag boolean hardcoded in your config

The two “not in the schema” items are the subtler half of the invariant. NODE_ENV is the framework’s own variable: you read it, but you don’t declare it. A hardcoded value isn’t configuration the environment supplies, so it has no place in env.ts even though it’s a “setting.” The schema is for what the environment provides, nothing more.

Invariant 3: the schema and .env.example list the same variables

Section titled “Invariant 3: the schema and .env.example list the same variables”

env.ts is what the app validates; the committed .env.example is what a newcomer copies to get started. The third invariant: they must list the same variables.

When they drift, onboarding fails silently. Someone adds a variable to the schema and forgets the example, so the next person copies .env.example, fills it in, runs pnpm build, and hits a validation error for a variable nobody told them about. Keep the two roles straight: .env.example is committed and holds a placeholder plus a # source: comment per variable; the git-ignored .env and .env.local hold the live values. A CI check enforces the match on every pull request later in the course.

Once every variable goes through the schema, lands on the correct side of the split, and matches the example, the schema stops being just a validator: it’s the inventory of what the app needs to run. Here is that file, the whole course’s configuration gathered into one map.

import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
// Database — the data layer
DATABASE_URL: z.url(),
DATABASE_URL_UNPOOLED: z.url(),
// Auth — sessions and sign-in
BETTER_AUTH_SECRET: z.string().min(1),
BETTER_AUTH_URL: z.url(),
// Email — transactional sends
RESEND_API_KEY: z.string().min(1),
EMAIL_FROM: z.email(),
// Invitations — signed invite tokens
INVITATION_SIGNING_SECRET: z.string().min(1),
// Rate limiting — Upstash Redis
UPSTASH_REDIS_REST_URL: z.url(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1),
// Billing — Stripe
STRIPE_SECRET_KEY: z.string().min(1),
// Observability — Sentry source maps
SENTRY_AUTH_TOKEN: z.string().min(1),
// Legacy — nothing reads this anymore
LEGACY_WEBHOOK_URL: z.url(),
},
client: {
NEXT_PUBLIC_APP_URL: z.url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_SENTRY_DSN: z.url(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
},
runtimeEnv: {
// one line per variable, mapping each schema field to process.env.X
// ...
},
});

Reading top to bottom is reading the app’s history, one group per feature you added. The database came first: DATABASE_URL and its unpooled twin, both z.url(), both server-only.

import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
// Database — the data layer
DATABASE_URL: z.url(),
DATABASE_URL_UNPOOLED: z.url(),
// Auth — sessions and sign-in
BETTER_AUTH_SECRET: z.string().min(1),
BETTER_AUTH_URL: z.url(),
// Email — transactional sends
RESEND_API_KEY: z.string().min(1),
EMAIL_FROM: z.email(),
// Invitations — signed invite tokens
INVITATION_SIGNING_SECRET: z.string().min(1),
// Rate limiting — Upstash Redis
UPSTASH_REDIS_REST_URL: z.url(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1),
// Billing — Stripe
STRIPE_SECRET_KEY: z.string().min(1),
// Observability — Sentry source maps
SENTRY_AUTH_TOKEN: z.string().min(1),
// Legacy — nothing reads this anymore
LEGACY_WEBHOOK_URL: z.url(),
},
client: {
NEXT_PUBLIC_APP_URL: z.url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_SENTRY_DSN: z.url(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
},
runtimeEnv: {
// one line per variable, mapping each schema field to process.env.X
// ...
},
});

Auth came next with Better Auth, a secret and a URL. Then email with Resend, an API key and a from address validated by z.email().

import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
// Database — the data layer
DATABASE_URL: z.url(),
DATABASE_URL_UNPOOLED: z.url(),
// Auth — sessions and sign-in
BETTER_AUTH_SECRET: z.string().min(1),
BETTER_AUTH_URL: z.url(),
// Email — transactional sends
RESEND_API_KEY: z.string().min(1),
EMAIL_FROM: z.email(),
// Invitations — signed invite tokens
INVITATION_SIGNING_SECRET: z.string().min(1),
// Rate limiting — Upstash Redis
UPSTASH_REDIS_REST_URL: z.url(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1),
// Billing — Stripe
STRIPE_SECRET_KEY: z.string().min(1),
// Observability — Sentry source maps
SENTRY_AUTH_TOKEN: z.string().min(1),
// Legacy — nothing reads this anymore
LEGACY_WEBHOOK_URL: z.url(),
},
client: {
NEXT_PUBLIC_APP_URL: z.url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_SENTRY_DSN: z.url(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
},
runtimeEnv: {
// one line per variable, mapping each schema field to process.env.X
// ...
},
});

Invitations added a signing secret, rate limiting the Upstash pair, billing the Stripe secret. The shapes stay minimal: z.string().min(1) for opaque tokens, z.url() for endpoints.

import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
// Database — the data layer
DATABASE_URL: z.url(),
DATABASE_URL_UNPOOLED: z.url(),
// Auth — sessions and sign-in
BETTER_AUTH_SECRET: z.string().min(1),
BETTER_AUTH_URL: z.url(),
// Email — transactional sends
RESEND_API_KEY: z.string().min(1),
EMAIL_FROM: z.email(),
// Invitations — signed invite tokens
INVITATION_SIGNING_SECRET: z.string().min(1),
// Rate limiting — Upstash Redis
UPSTASH_REDIS_REST_URL: z.url(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1),
// Billing — Stripe
STRIPE_SECRET_KEY: z.string().min(1),
// Observability — Sentry source maps
SENTRY_AUTH_TOKEN: z.string().min(1),
// Legacy — nothing reads this anymore
LEGACY_WEBHOOK_URL: z.url(),
},
client: {
NEXT_PUBLIC_APP_URL: z.url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_SENTRY_DSN: z.url(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
},
runtimeEnv: {
// one line per variable, mapping each schema field to process.env.X
// ...
},
});

Every browser-bound variable lives here, each prefixed NEXT_PUBLIC_: the app URL, the Stripe publishable key, the Sentry DSN, the PostHog key. This is the firewall from invariant 2 made concrete.

import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
// Database — the data layer
DATABASE_URL: z.url(),
DATABASE_URL_UNPOOLED: z.url(),
// Auth — sessions and sign-in
BETTER_AUTH_SECRET: z.string().min(1),
BETTER_AUTH_URL: z.url(),
// Email — transactional sends
RESEND_API_KEY: z.string().min(1),
EMAIL_FROM: z.email(),
// Invitations — signed invite tokens
INVITATION_SIGNING_SECRET: z.string().min(1),
// Rate limiting — Upstash Redis
UPSTASH_REDIS_REST_URL: z.url(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1),
// Billing — Stripe
STRIPE_SECRET_KEY: z.string().min(1),
// Observability — Sentry source maps
SENTRY_AUTH_TOKEN: z.string().min(1),
// Legacy — nothing reads this anymore
LEGACY_WEBHOOK_URL: z.url(),
},
client: {
NEXT_PUBLIC_APP_URL: z.url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_SENTRY_DSN: z.url(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
},
runtimeEnv: {
// one line per variable, mapping each schema field to process.env.X
// ...
},
});

A finding. The build still requires it, but no code reads it anymore. Dead config is a documentation lie: the file claims the app needs a variable it doesn’t. An orphaned variable is a deletion, not a default.

1 / 1

Top to bottom, the schema lists the app’s entire dependency on the outside world, grouped by feature, which makes it your most honest documentation as long as it stays in lockstep with what the code reads. That is why orphaned LEGACY_WEBHOOK_URL is a real finding: documentation that lies is worse than none, because people trust it.

Invariant 4: SKIP_ENV_VALIDATION is an escape hatch, not a setting

Section titled “Invariant 4: SKIP_ENV_VALIDATION is an escape hatch, not a setting”

@t3-oss/env-nextjs honors a SKIP_ENV_VALIDATION flag that returns the env object without running a single schema, switching off the whole gate. It has exactly two legitimate homes:

  1. A container image build where server secrets aren’t available yet, so you build now and inject values at runtime.
  2. A type-check or lint CI job that doesn’t need real values to run.

Anywhere else, it’s the bug. Used correctly, it’s one line in a build script:

Terminal window
# Dockerfile: secrets are injected at runtime, not build time
SKIP_ENV_VALIDATION=1 pnpm build

The trap is setting it in the production runtime to silence a missing-variable error. The build goes green, but the gate is off for good: the missing variable resurfaces as a 3am 500 on the first request instead of a build failure on your terminal.

So when the build says a variable is missing, set it; don’t silence it.

The four invariants assume every variable behaves the same in every environment. Two complications break that assumption.

Some variables are required in production but absent in development. STRIPE_WEBHOOK_SECRET and SENTRY_AUTH_TOKEN are the canonical examples: no one needs them to run the app locally, but production must have them.

Make them unconditionally required and every local pnpm build fails over variables nobody needs. Make them optional and a production deploy can ship with Sentry silently un-authed and source maps never uploaded. So branch the schema on the environment, giving each the contract it needs:

src/env.ts
const isProd = process.env.NODE_ENV === 'production';
server: {
SENTRY_AUTH_TOKEN: isProd ? z.string().min(1) : z.string().optional(),
},

Required in production, optional everywhere else. This is one of the two sanctioned process.env.NODE_ENV reads from invariant 1.

Other variables aren’t present or absent; they hold a different value in each environment. The app’s own base URL is the cleanest case: http://localhost:3000 on your laptop, a unique per-branch URL on a Vercel preview, your real domain in production.

You can’t hardcode it or pack three values into one variable. Instead a small helper in /lib resolves the URL for whatever environment it runs in:

src/lib/get-app-url.ts
import { env } from '@/env';
export const getAppUrl = (): string => {
if (process.env.NODE_ENV === 'production') return env.NEXT_PUBLIC_APP_URL;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return 'http://localhost:3000';
};

Vercel injects VERCEL_URL (and VERCEL_PROJECT_PRODUCTION_URL) without the scheme, as a bare host like my-app-git-feature.vercel.app; forget to prepend https:// and every link the helper builds breaks. Those raw process.env reads look like an invariant-1 violation but aren’t: Vercel injects them, they aren’t app secrets, and they live only inside this one helper.

“Single source of truth” extends past validation to derived config: the URL is computed, not stored, and getAppUrl() is the one place that computation lives, just as env.ts is the one place validation lives.

Each check below runs against any codebase as a repeatable pass over the four invariants; every hit is a finding to fix.

Every access through the typed env. Grep process.env across the repo; every hit outside env.ts is a finding. Allowed exceptions: process.env.NODE_ENV and rare framework reads like next.config.ts. (Invariant 1.)
untested
Server/client split clean. No server-block variable imported in a Client Component (the build catches it; confirm import 'server-only' guards every secret-bearing module), and no NEXT_PUBLIC_* name that grants real authority. (Invariant 2.)
untested
Schema and .env.example in lockstep. Diff the two variable lists; every schema variable has a placeholder and a # source: line in .env.example, and vice versa. Flag any orphaned schema variable no code reads. (Invariant 3.)
untested
No SKIP_ENV_VALIDATION outside the two sanctioned scripts. Grep for it; it belongs only in the Docker build and the type-check CI job, never in production runtime env vars. (Invariant 4.)
untested
Production-only vars are NODE_ENV-conditional, and the normal build passes without SKIP_ENV_VALIDATION. (The extensions.)
untested

This audit joins the chapter’s other deliverables, which the next chapter runs against a seeded codebase.

One boundary stays out of scope: per-tenant, white-label resolution, where configuration differs per customer. One schema, split correctly and kept honest, is the right baseline here.

Keep these sources open while you run the audit.