У приложения есть настройки, которые различаются между стендами: адрес бэкенда, ключ карт, включённость экспериментальной вкладки. Обычно их держат в переменных окружения — и на этом месте возникают две ошибки подряд. Первая: считать, что переменная окружения — это секрет. Вторая: узнавать об опечатке в имени переменной по белому экрану на продакшене.
Разберём обе.
Всё, что попало в сборку, — публично
Фронтенд собирается в файлы, которые скачивает браузер. Сборщик подставляет значения переменных прямо в код — буквально, текстом:
// исходник
const url = import.meta.env.VITE_API_URL;
// в собранном файле
const url = "https://api.example.com";
Отсюда простое следствие: в браузере нет секретов. Ключ, попавший в сборку, доступен любому, кто откроет вкладку разработчика — или просто скачает файл скрипта и поищет в нём строку. Ни минификация, ни хитрая склейка не помогают: значение всё равно есть в файле, иначе код не работал бы.
Сборщики стараются об этом напомнить. Vite подставляет в клиентский код только переменные с префиксом VITE_, Next — с NEXT_PUBLIC_. Префикс — не защита, а подпись: «я понимаю, что это увидят все».
Поэтому в переменных фронтенда живут только настройки:
- адрес API и других сервисов,
- публичные ключи, которые и рассчитаны на клиент (карты, аналитика, приём платежей на стороне провайдера),
- включатели возможностей,
- название стенда и версия сборки для отладки.
А вот чего там быть не должно ни при каких обстоятельствах: ключей платёжного провайдера с правом списания, токенов доступа к базе, паролей интеграций, приватных ключей подписи. Если такому ключу нужно вызвать что-то из браузера — между браузером и провайдером ставят свой бэкенд, и ключ остаётся на нём.
Отдельно: .env не хранят в репозитории (кроме файла-образца без значений), даже если внутри «всего лишь адреса». Файл живёт вместе с проектом дольше, чем содержимое, и однажды туда допишут лишнее.
Опечатка в имени переменной ломает всё молча
Вторая ошибка коварнее. Переменные окружения — это строки, которые никто не проверяет:
const url = import.meta.env.VITE_API_URL; // undefined, если забыли задать
fetch(`${url}/products`); // запрос уходит на /undefined/products
Приложение соберётся, запустится и покажет экран. Ошибка появится позже — при первом запросе, у пользователя, в виде непонятного отказа. Причём чаще всего это происходит именно на новом стенде, где переменную задать забыли.
Лечится одним приёмом: проверять окружение на старте, схемой, целиком.
// src/shared/config/env.ts
import { z } from "zod";
const envSchema = z.object({
VITE_API_URL: z.string().url(),
VITE_ENV_NAME: z.enum(["local", "dev", "stage", "prod"]),
VITE_SENTRY_DSN: z.string().url().optional(),
VITE_FEATURE_NEW_CART: z.coerce.boolean().default(false),
});
const parsed = envSchema.safeParse(import.meta.env);
if (!parsed.success) {
const problems = parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Неверные переменные окружения:\n${problems}`);
}
export const env = parsed.data;
Дальше по коду обращаются только к env, а не к import.meta.env напрямую:
import { env } from "@/shared/config/env";
export const apiClient = createClient({ baseUrl: env.API_URL });
Что это даёт. Приложение падает сразу и с внятным текстом, а не через десять минут на непонятном запросе. Типы становятся настоящими: env.VITE_FEATURE_NEW_CART — это boolean, а не строка "false", которая в условии истинна. Значения по умолчанию описаны в одном месте. И появляется список всего, что нужно задать для запуска, — сама схема и есть документация.
Запрет прямого доступа тоже стоит закрепить правилом линтера, иначе через месяц в проекте снова появится import.meta.env.VITE_... в случайном файле:
"no-restricted-properties": ["error", {
object: "import.meta",
property: "env",
message: "Окружение — только через @/shared/config/env",
}],
Проверка стендов между собой
Схема ловит отсутствующую переменную на запуске приложения. Но есть ошибка раньше: переменную добавили в файл для локальной разработки, а в настройки стенда — забыли. Локально всё работает, на стенде падает.
Помогает простой скрипт, который сверяет наборы имён между окружениями и падает, если где-то чего-то нет:
// scripts/env-check.ts
const required = Object.keys(envSchema.shape);
const files = [".env.development", ".env.production"];
for (const file of files) {
const defined = new Set(readEnvKeys(file));
const missing = required.filter((key) => !defined.has(key) && !isOptional(key));
if (missing.length) {
throw new Error(`${file}: не хватает ${missing.join(", ")}`);
}
}
Такой скрипт ставят в сборку рядом с линтером. Он занимает вечер, а ловит целый класс ночных разборов «почему на стенде белый экран».
Настройки, которые приходят в бою
Есть случай, когда подстановка на сборке неудобна: один и тот же образ приложения выкатывают на несколько стендов. Пересобирать его ради другого адреса API — значит выкатывать уже не то, что тестировали.
Тогда настройки отдают не сборщиком, а во время выполнения: сервер (или контейнер при старте) кладёт рядом маленький файл с конфигурацией, приложение читает его первым делом. Схема при этом остаётся ровно та же — просто разбирается не import.meta.env, а содержимое ответа.
Правило безопасности не меняется: то, что уехало в браузер, публично. Способ доставки на это не влияет.
Коротко
- Всё, что сборщик подставил в код, попадает в браузер и доступно любому: секретов во фронтенде нет.
- Префиксы
VITE_иNEXT_PUBLIC_— не защита, а явное согласие сделать значение публичным. - В переменных фронтенда живут адреса, публичные ключи и включатели; ключи с правом действия — только на бэкенде.
- Незаданная переменная — это
undefined, который ломает приложение молча и позже; проверять её нужно на старте. - Схема окружения даёт понятное падение, настоящие типы, значения по умолчанию и список того, что нужно для запуска.
- Прямой доступ к переменным запрещают правилом линтера, оставляя один модуль-вход.
- Сверка наборов переменных между стендами ловит «забыли добавить на стенде» до выката.
- Если один образ едет на несколько стендов, настройки читают во время выполнения, но правило публичности остаётся прежним.
Что почитать дальше
- Границы архитектуры — как правила линтера удерживают договорённости.
- Статические сайты и MDX — тот же приём проверки схемой, только для контента.
- Клиент из OpenAPI — откуда берётся адрес API и типизированный клиент к нему.