Каждый Next.js-проект, который я наследовал, содержал один и тот же баг: отсутствующая или с опечаткой переменная окружения, которая работает локально (потому что .env.local её содержит), но ломается в продакшне (потому что деплой забыл её). Ошибка всегда криптичная — undefined is not a valid URL или fetch failed без контекста.
t3-env решает это хорошо. Но это зависимость со своим синтаксисом конфига, и для большинства проектов это оверкилл. Вот 40-строчная альтернатива, которую я использую в каждом проекте.
Сетап
Один файл. Ноль зависимостей.
// 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!,
}
Вот и всё. 40 строк. Вот что это даёт:
Что ты получаешь
1. Валидация при билде
Если DATABASE_URL отсутствует или не является валидным URL, билд падает с ясной ошибкой:
Invalid server environment variables: { DATABASE_URL: ['Required'] }
Не «fetch failed at line 247». Не undefined is not a function. Ясное сообщение, называющее конкретную переменную.
2. Полный IntelliSense
Каждая ссылка env.DATABASE_URL получает автокомплит и проверку типов. Опечатка env.DATABSE_URL? TypeScript поймает.
// ✅ TypeScript знает, что это строка
const url = env.DATABASE_URL
// ❌ Ошибка TypeScript: Property 'DATABSE_URL' does not exist
const url = env.DATABSE_URL
3. Валидация формата
Zod-схема не просто проверяет «существует» — она проверяет формат:
z.string().url()— должен быть валидным URLz.string().startsWith('sk_')— должен быть Stripe secret key (не publishable)z.string().min(1)— не должен быть пустой строкой
Я поймал минимум 3 бага одной валидацией startsWith. Самый частый: тестовый Stripe-ключ в продакшне или publishable key где нужен secret.
4. Разделение клиент/сервер
Объект client перечисляет только NEXT_PUBLIC_* переменные. Это предотвращает случайное раскрытие серверных секретов в браузере. Если попробуешь добавить DATABASE_URL в клиентскую схему, значение будет undefined (Next.js экспозит только NEXT_PUBLIC_* клиенту).
Гард typeof window === 'undefined' означает, что серверная валидация не запускается в браузере — где серверные env-переменные корректно отсутствуют.
Паттерн для разных окружений
Для проектов с различиями стейджинг/продакшн добавь валидацию по окружению:
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' }
),
})
Это ловит точно тот баг, который я описал в Почему мой Stripe тест-режим прошёл, а продакшн сломался — стейджинговый secret key задеплоенный в продакшн.
Почему не t3-env
t3-env хорош. Я его использовал. Но он добавляет:
- Зависимость (со своим циклом обновлений)
- Кастомный формат конфига (
createEnv({ ... })) - Каплинг с фреймворком (Next.js-специфичный адаптер)
Подход на чистом Zod имеет ноль новых зависимостей (Zod уже есть, если валидируешь хоть что-то), использует стандартный синтаксис Zod, который твоя команда уже знает, и работает в любом TypeScript-проекте — не только Next.js.
Для большого монорепо с расшаренными env-схемами между пакетами фича extends из t3-env стоит зависимости. Для одного Next.js-приложения 40-строчного файла достаточно.
Откуда импортировать
// ✅ Всегда импортируй из lib/env
import { env } from '@/lib/env'
const dbUrl = env.DATABASE_URL
// ❌ Никогда не используй process.env напрямую
const dbUrl = process.env.DATABASE_URL
Добавь ESLint-правило для энфорса:
// .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',
},
],
}
Это ловит случайные обращения к process.env и направляет разработчиков к типизированной альтернативе.
5-минутная миграция
Если есть существующий проект на сыром process.env:
- Создай
lib/env.tsсо схемой выше - Замени каждый
process.env.Xнаenv.X(глобальный find-replace) - Запусти
npx tsc --noEmit— TypeScript укажет на переменные, которые ты пропустил в схеме - Задеплой — если какая-то переменная отсутствует, билд скажет какая именно
Шаг 3 — момент магии. TypeScript становится твоим инструментом аудита — он находит каждую ссылку на env в кодовой базе и говорит, есть ли она в схеме.
Хочешь чистый, валидированный, полностью типизированный конфиг для своего Next.js-проекта? Напиши — это часть базового сетапа, который я шиплю с каждым новым билдом.