Skip to main content
Назад до блогу
4 хв читання

Типізовані змінні оточення в Next.js без t3-env

40-рядковий сетап для повністю типізованих, валідованих змінних оточення в Next.js. Нуль залежностей, рантайм-валідація при старті та IntelliSense скрізь.

next.jssecurity

Кожен 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-схема не просто перевіряє «існує» — вона перевіряє формат:

Я зловив мінімум 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 гарний. Я його використовував. Але він додає:

Підхід на чистому 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:

  1. Створи lib/env.ts зі схемою вище
  2. Заміни кожен process.env.X на env.X (глобальний find-replace)
  3. Запусти npx tsc --noEmit — TypeScript вкаже на змінні, які ти пропустив у схемі
  4. Задеплой — якщо якась змінна відсутня, білд скаже яка саме

Крок 3 — момент магії. TypeScript стає твоїм інструментом аудиту — він знаходить кожне посилання на env у кодовій базі і каже, чи є воно в схемі.


Хочеш чистий, валідований, повністю типізований конфіг для свого Next.js-проєкту? Напиши — це частина базового сетапу, який я шиплю з кожним новим білдом.