Skip to main content
Back to Blog
4 min read

Typed environment variables in Next.js without t3-env

A 40-line setup for fully typed, validated environment variables in Next.js. No external dependencies, runtime validation on startup, and IntelliSense everywhere.

next.jssecurity

Every Next.js project I've inherited has the same bug: a missing or misspelled environment variable that works locally (because .env.local has it) but breaks in production (because the deploy forgot it). The error is always cryptic — undefined is not a valid URL or fetch failed with no context.

t3-env solves this well. But it's a dependency with its own config syntax, and for most projects it's overkill. Here's the 40-line alternative I use in every project.

The setup

One file. No dependencies.

// lib/env.ts
import { z } from 'zod'

const server = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  SENTRY_DSN: z.string().url().optional(),
  TELEGRAM_BOT_TOKEN: z.string().min(1),
  TELEGRAM_CHAT_ID: z.string().min(1),
})

const client = z.object({
  NEXT_PUBLIC_SITE_URL: z.string().url(),
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
  NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(),
})

const serverEnv = server.safeParse(process.env)
const clientEnv = client.safeParse({
  NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
    process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
  NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
})

if (!serverEnv.success && typeof window === 'undefined') {
  console.error(
    'Invalid server environment variables:',
    serverEnv.error.flatten().fieldErrors
  )
  throw new Error('Invalid server environment variables')
}

if (!clientEnv.success) {
  console.error(
    'Invalid client environment variables:',
    clientEnv.error.flatten().fieldErrors
  )
  throw new Error('Invalid client environment variables')
}

export const env = {
  ...serverEnv.data!,
  ...clientEnv.data!,
}

That's it. 40 lines. Here's what you get:

What this gives you

1. Build-time validation

If DATABASE_URL is missing or isn't a valid URL, the build fails with a clear error:

Invalid server environment variables: { DATABASE_URL: ['Required'] }

Not "fetch failed at line 247." Not undefined is not a function. A clear message naming the exact variable.

2. Full IntelliSense

Every env.DATABASE_URL reference gets autocomplete and type checking. Typo env.DATABSE_URL? TypeScript catches it.

// ✅ TypeScript knows this is a string
const url = env.DATABASE_URL

// ❌ TypeScript error: Property 'DATABSE_URL' does not exist
const url = env.DATABSE_URL

3. Format validation

The Zod schema doesn't just check "exists" — it checks format:

I've caught at least 3 bugs with the startsWith validation alone. The most common: using a test Stripe key in production, or a publishable key where a secret key is needed.

4. Client/server separation

The client object only lists NEXT_PUBLIC_* variables. This prevents accidentally exposing server secrets to the browser. If you try to add DATABASE_URL to the client schema, the value will be undefined (Next.js only exposes NEXT_PUBLIC_* to the client).

The typeof window === 'undefined' guard means server-only validation doesn't run in the browser — where server env vars are correctly absent.

The pattern for different environments

For projects with staging/production differences, add environment-specific validation:

const server = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().refine(
    (key) => {
      if (process.env.NODE_ENV === 'production') {
        return key.startsWith('sk_live_')
      }
      return key.startsWith('sk_test_')
    },
    { message: 'Stripe key must match the environment' }
  ),
})

This catches the exact bug I described in Why my Stripe test mode passed and production failed — a staging secret key deployed to production.

Why not t3-env

t3-env is good. I've used it. But it adds:

The Zod-only approach has zero new dependencies (you already have Zod if you validate anything), uses standard Zod syntax that your team already knows, and works in any TypeScript project — not just Next.js.

For a large monorepo with shared env schemas across packages, t3-env's extends feature is worth the dependency. For a single Next.js app, the 40-line file is enough.

Where to import from

// ✅ Always import from lib/env
import { env } from '@/lib/env'
const dbUrl = env.DATABASE_URL

// ❌ Never use process.env directly
const dbUrl = process.env.DATABASE_URL

Add an ESLint rule to enforce this:

// .eslintrc.js
rules: {
  'no-restricted-syntax': [
    'error',
    {
      selector: 'MemberExpression[object.name="process"][property.name="env"]',
      message: 'Use env from @/lib/env instead of process.env',
    },
  ],
}

This catches stray process.env accesses and points developers to the typed alternative.

The 5-minute migration

If you have an existing project using raw process.env:

  1. Create lib/env.ts with the schema above
  2. Replace every process.env.X with env.X (global find-replace)
  3. Run npx tsc --noEmit — TypeScript will flag any variables you missed in the schema
  4. Deploy — if any variable is missing, the build tells you which one

Step 3 is the magic moment. TypeScript becomes your audit tool — it finds every env reference in the codebase and tells you if it's in the schema.


Want a clean, validated, fully typed config for your Next.js project? Let's talk — this is part of the baseline setup I ship with every new build.