Кожен 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-проєкту? Напиши — це частина базового сетапу, який я шиплю з кожним новим білдом.