Skip to content
Chapter 41Lesson 2

Type-safe env vars with @t3-oss/env-nextjs

This project holds your first real secret: the connection string to your Postgres database. A missing DATABASE_URL should fail pnpm build on your machine, before you deploy, not crash the first request after you ship.

You get there by routing every piece of configuration through one typed boundary: a module called env.ts that validates the environment the moment it loads. Remove a required variable and the build refuses to finish, naming exactly what is missing:

pnpm build with DATABASE_URL removed
Invalid environment variables: { DATABASE_URL: [ 'Invalid input' ] }
at createEnv (.../@t3-oss/env-core/dist/index.js)
Error: Invalid environment variables.

The crash moves from a request handler in production to your terminal during next build, with the variable named.

Read process.env.DATABASE_URL directly in the data layer and a missing value goes unnoticed until the first request hands undefined to the database client and throws a 500. Validating config at build time turns that runtime crash into a build failure you cannot miss.

@t3-oss/env-nextjs is a thin Zod wrapper that does exactly this: it validates your env at build time, enforces the NEXT_PUBLIC_ prefix (set on anything the browser may see, omitted for server-only secrets), and exports typed values you import instead of process.env. The starter already ships this boundary in env.ts: a server block for the project’s three secrets, an empty client block for later analytics keys, and a runtimeEnv map linking each field to its process.env entry. Your job is to create .env, then prove the boundary fires by removing a variable and watching the build fail.

This buys you two guarantees. Application code imports from env, never process.env, which is where validation runs and where env.DATABASE_URL is typed string rather than string | undefined, so you never null-check a value the build already guaranteed. And the package rejects a NEXT_PUBLIC_* var in server or a server-only var in client, so a database password cannot slip into the browser bundle by accident.

Drizzle already reads through env in db/index.ts, your reference for wiring config. One trap: SKIP_ENV_VALIDATION disables the whole check, legitimate only for a CI build that runs without secrets, never a way to silence a validation error.

Removing DATABASE_URL from .env makes pnpm build fail with an error that names the missing variable; restoring it makes the build pass again.
tested
The app boots and reads DATABASE_URL through the typed env export, with no remaining process.env.DATABASE_URL access in application code.
untested
.env holds the real secret and is git-ignored, while .env.example is committed and names every variable the app expects.
untested

There is no new code to write. Copy .env.example to .env and run pnpm build to confirm it passes. Then verify the boundary: delete the DATABASE_URL line from .env, rebuild, read the error, and restore it — before opening the walkthrough, so you feel the build fail with your own variable removed.

Reference walkthrough

Here is src/env.ts as it ships. Three parts 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 each server-only variable with the Zod schema that validates it: z.url() rejects a non-URL, and z.coerce.number().default(1) turns the environment’s string into a number, falling back to 1 when SEED is absent. The client block is empty because nothing is exposed to the browser yet; any entry there must carry the NEXT_PUBLIC_ prefix.

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 looks redundant but is not: Next.js inlines NEXT_PUBLIC_* variables at build time and keeps server variables dynamic, so the validator can’t read process.env by key name and must be told which process.env.X backs each field. This is why process.env appears in exactly one place in the app.

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 runs the schemas against runtimeEnv when the module loads, throws if a required variable is missing or malformed, and returns a typed object. Because validation already ran, env.DATABASE_URL is typed string, not string | undefined — application code never null-checks it.

1 / 1

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:

src/db/index.ts (excerpt)
import { env } from '@/env';
// ...
const client = postgres(env.DATABASE_URL);

.env.example is committed and documents every variable with safe local defaults — the file a new contributor copies on day one:

.env.example (committed)
# 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/app
DATABASE_URL_UNPOOLED=postgres://postgres:postgres@localhost:5432/app
SEED=1

.env is the copy you create, holding the real values. It is never committed — .gitignore excludes .env*, so a secret can’t leak through git by accident.

In production the values live in your host’s dashboard, but the same createEnv call validates them at build time. A variable you forgot to set fails the deploy build before any traffic reaches the new version.

When SKIP_ENV_VALIDATION is set, @t3-oss/env-nextjs returns the env object without running any schema. It has one honest use: a build step that legitimately runs without the secrets, such as a CI job that only type-checks. If a real build throws “Invalid environment variables”, the variable is missing — set it, don’t silence it.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 2

Instead of running a full next build, the spec imports the env module directly; the same validation fires, so a clean import passes and an import that throws while naming DATABASE_URL fails. Expect three passing tests:

Expected output
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 what the tests can’t reach:

pnpm build succeeds with your .env in place.
untested
Removing DATABASE_URL from .env and running pnpm build fails with an error naming the missing variable; restoring it makes the build pass again — the tested behavior, now confirmed on a real build.
untested
Application code reads env.DATABASE_URL, not process.envdb/index.ts already does this.
untested
.env is git-ignored and .env.example is committed.
untested