Type-safe environment variables with @t3-oss/env-nextjs
This project is the first one in the course to hold a real secret — the connection string to your Postgres database — and that changes the stakes of a missing variable. The goal of this lesson is narrow and worth installing once, properly: a missing DATABASE_URL should fail pnpm build on your machine, before you deploy, rather than crash the first request after you ship.
The way you get there is to route every piece of configuration through one typed boundary. The project reads its config from a module called env.ts, and that module validates the environment the moment it loads. Remove a required variable and the build refuses to finish, printing an error that names exactly what is missing:
❌ Invalid environment variables: { DATABASE_URL: [ 'Invalid input' ] } at createEnv (.../@t3-oss/env-core/dist/index.js)Error: Invalid environment variables.That is the whole difference. The downstream call that would have thrown undefined is not a connection string inside a request handler at 3am instead throws inside next build, on your terminal, with the variable named — a problem you fix in ten seconds versus an outage that lasts as long as a redeploy.
Your mission
Section titled “Your mission”The failure mode this lesson pre-empts is one every experienced engineer has hit at least once. You read process.env.DATABASE_URL directly somewhere deep in the data layer. The deploy succeeds — nothing validates an env var by default. The app boots — the module that needs the variable hasn’t run yet. Then the first real request hits a handler, the handler reaches for process.env.DATABASE_URL, gets undefined, passes it to the database client, and the client throws. The user sees a 500, and the outage lasts until you notice, set the variable, and redeploy. Validating configuration at build time converts that runtime crash into a build failure you cannot miss.
The 2026 default for this is @t3-oss/env-nextjs. It is a thin wrapper around a Standard Schema validator — here that is Zod 4 — that runs your env through a schema at build time. It enforces the Next.js naming convention (a NEXT_PUBLIC_ prefix for anything the browser is allowed to see, no prefix for server-only secrets) and exports typed values that the rest of the app imports in place of touching process.env. The starter already ships this boundary fully written: an env.ts with a server block holding the project’s three server-only variables, an empty client block staged for the analytics keys that arrive in later chapters, and a runtimeEnv map that tells the validator which process.env entry backs each schema field. Your job is not to re-derive that file — it is to wire the project to it by creating your .env, and then to prove the boundary actually fires by removing a variable and watching the build fail.
Two constraints shape how you work from here on. Application code imports configuration from the project’s env module, never from process.env directly — that import is the seam where validation runs, and it is what types env.DATABASE_URL as string rather than string | undefined, so you never have to null-check a value that the build already guaranteed. And the package refuses a NEXT_PUBLIC_* variable inside the server block and a server-only variable inside client, which makes the classic leaked-secret bug — shipping a database password to the browser bundle — something you have to fight the tooling to write.
A few things are deliberately out of scope. You will not author Zod schemas in depth here; that is the next chapter’s subject. You will not wire Drizzle to DATABASE_URL — the starter’s db/index.ts already does that, and it is your reference for what reading through env looks like in practice. The Vercel deploy flow and secret rotation come much later in the course, and the course commits to Zod, so Valibot as an alternative validator is not on the table. The one trap to name now is SKIP_ENV_VALIDATION: the package honors an environment flag that skips the whole check. It is legitimate for exactly one situation — a CI build that genuinely runs without the secrets present, where you set the flag deliberately in that one script. It is never the thing you reach for to make a validation error go away.
DATABASE_URL from .env makes pnpm build fail with an error that names the missing variable; restoring it makes the build pass again.DATABASE_URL through the typed env export, with no remaining process.env.DATABASE_URL access in application code..env holds the real secret and is git-ignored, while .env.example is committed and names every variable the app expects.Coding time
Section titled “Coding time”There is no new code to write in this lesson. Copy .env.example to .env, run pnpm build to confirm it passes, then run the verification the boundary exists for: delete the DATABASE_URL line from .env, rebuild, read the error, and restore it. Do that before you open the walkthrough below — the point of the exercise is to feel the build fail with your own variable removed.
Reference walkthrough
Because nothing here is student-authored, the “solution” is the provided boundary read closely, plus the reasoning behind the verification.
The env boundary
Section titled “The env boundary”This is src/env.ts exactly as it ships. Step through the three parts that matter.
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
// The single env boundary: application code imports `env`, never `process.env`.// createEnv validates at build time — a missing/invalid DATABASE_URL fails// `next build` with a message naming the variable.export const env = createEnv({ server: { DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), SEED: z.coerce.number().default(1), }, client: {}, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL_UNPOOLED: process.env.DATABASE_URL_UNPOOLED, SEED: process.env.SEED, },});The server block lists every server-only variable with the Zod schema that validates it. z.url() rejects a value that is not a URL, and z.coerce.number().default(1) turns the string the environment always hands back into a number, falling back to 1 when SEED is absent. The client block is empty because nothing in this project is exposed to the browser yet — it fills up when analytics keys land in a later chapter, and every entry there will carry the NEXT_PUBLIC_ prefix the package requires.
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
// The single env boundary: application code imports `env`, never `process.env`.// createEnv validates at build time — a missing/invalid DATABASE_URL fails// `next build` with a message naming the variable.export const env = createEnv({ server: { DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), SEED: z.coerce.number().default(1), }, client: {}, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL_UNPOOLED: process.env.DATABASE_URL_UNPOOLED, SEED: process.env.SEED, },});The runtimeEnv map is the part that looks redundant but is not. Next.js inlines NEXT_PUBLIC_* variables into the bundle at build time and keeps server variables dynamic, so the validator cannot just read process.env by key name — it needs to be told explicitly which process.env.X backs each schema field. This map is that wiring; it is also why the only place process.env appears in the whole app is right here, inside the boundary.
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
// The single env boundary: application code imports `env`, never `process.env`.// createEnv validates at build time — a missing/invalid DATABASE_URL fails// `next build` with a message naming the variable.export const env = createEnv({ server: { DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), SEED: z.coerce.number().default(1), }, client: {}, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL_UNPOOLED: process.env.DATABASE_URL_UNPOOLED, SEED: process.env.SEED, },});createEnv is the seam. It runs the schemas against the values from runtimeEnv the moment this module loads, throws if any required variable is missing or malformed, and returns a typed object. Because validation already happened, env.DATABASE_URL is typed string, not string | undefined — application code imports env and reads a value it never has to null-check.
src/db/index.ts is the example to copy. It imports env and passes env.DATABASE_URL straight into the Postgres client — no process.env, no null check, because the boundary already guaranteed the value:
import { env } from '@/env';
// ...const client = postgres(env.DATABASE_URL);.env.example versus .env
Section titled “.env.example versus .env”Two files, two jobs. .env.example is committed to the repo and documents every variable the app expects, with safe local defaults — it is the file a new contributor copies on day one:
# Copy this file to .env and adjust if your local Postgres differs.# The migrate/seed scripts load .env via dotenv-cli; next build reads the# environment directly. Locally both URLs point at the same Docker Postgres;# the pooled/unpooled split exists so Unit 20 can plug Neon in without renaming.DATABASE_URL=postgres://postgres:postgres@localhost:5432/appDATABASE_URL_UNPOOLED=postgres://postgres:postgres@localhost:5432/appSEED=1.env is the file you create from it. It holds the real values and is never committed — the .gitignore excludes .env*, so your secret cannot leak through git even by accident. Locally the two database URLs point at the same Docker Postgres, so don’t read anything into the split yet; the reason for two variables is purely about the deploy story, which the next lesson and the deployment chapter later in the course unpack.
The connection to production is worth one sentence: when you deploy, the production values live in your host’s dashboard rather than a .env file, but they are validated by the exact same createEnv call at build time. A variable you forgot to set in the dashboard fails the deploy build before any traffic shifts to the new version — the same guarantee you are about to verify locally, applied to the place it matters most. The deploy chapter in a later unit owns that flow end to end.
The SKIP_ENV_VALIDATION escape hatch
Section titled “The SKIP_ENV_VALIDATION escape hatch”@t3-oss/env-nextjs reads a SKIP_ENV_VALIDATION flag and, when it is set, returns the env object without running a single schema. There is exactly one honest use for it: a build step that legitimately runs without the secrets present — a CI job that only type-checks, for instance — where you set the flag in that one script on purpose. Reaching for it because a build threw “Invalid environment variables” is defeating the feature you installed this lesson to get. If the build complains a variable is missing, the variable is missing; set it, don’t silence it.
Official @t3-oss/env-nextjs docs: createEnv, the server/client/runtimeEnv blocks, and SKIP_ENV_VALIDATION.
How Next.js loads .env files and why NEXT_PUBLIC_ vars are inlined into the browser bundle at build time.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 2The spec does not shell out to a real next build — it imports the env boundary under a controlled environment, which reproduces the build-time behavior exactly: a clean import is a passing build, and an import that throws while naming DATABASE_URL is the failure you must be able to produce. Expect three passing tests:
✓ tests/lessons/Lesson 2.test.ts (3) ✓ the env boundary validates DATABASE_URL at build time (req 1) (3) ✓ rejects a missing DATABASE_URL and names it in the failure ✓ accepts a valid environment and exposes DATABASE_URL through the typed env export ✓ restoring DATABASE_URL turns a failing build back into a passing one
Test Files 1 passed (1) Tests 3 passed (3)Then confirm by hand the two requirements the test runner cannot reach, ticking each as you go:
pnpm build succeeds with your .env in place.DATABASE_URL from .env and running pnpm build fails with an error naming the missing variable; restoring it makes the build pass again. (This is the tested requirement, seen with your own eyes on a real build.)env.DATABASE_URL, not process.env — db/index.ts is the example already doing this..env is git-ignored and .env.example is committed.