Серверные vs клиентские переменные — главная ловушка
В Next.js (и Vite) переменные делятся на два мира: те, что видит сервер, и те, что видит браузер. Граница — префикс. Тот, кто этого не знает, рано или поздно сольёт ключ. Тот, кто знает, спит спокойно.
Почему вообще есть две стороны
Современный фреймворк собирает один проект в две части. Серверный код выполняется в Node.js на вашем сервере — он может прочитать любой файл, любую переменную. Клиентский код собирается в JS-бандл и улетает в браузер пользователя — там доступно только то, что мы туда специально положили. Переменная без префикса попадает только на сервер. С префиксом — попадает В БАНДЛ, и любой пользователь может открыть DevTools → Sources и прочитать её значение.
Префиксы в разных фреймворках
Next.js: NEXT_PUBLIC_X — клиент видит, X без префикса — только сервер. Vite: VITE_X — клиент, X — только сервер. Create React App (legacy): REACT_APP_X. Astro: PUBLIC_X. Логика везде одна — спецпрефикс прокидывает в бандл, без префикса значение остаётся на сервере. Уберите префикс — и ваш Client Component получит undefined.
Что МОЖНО под публичным префиксом
Всё, что и так публично доступно. Адрес вашего же API (
). ID Google Analytics. Stripe Publishable Key (pk_...) — он специально для клиента. Sentry DSN. Всё, что любой пользователь и так увидит при работе с сайтом.
Что НЕЛЬЗЯ — даже случайно
Любые sk_.... Пароли БД. JWT-секреты. SMTP-пароли. Stripe Secret Key. AWS Access Key. Если вы в момент написания подумали «а удобно бы было дёрнуть OpenAI прямо из клиента» — остановитесь. Сделайте серверный API-роут, и пусть клиент дёргает его, а роут уже идёт в OpenAI с серверным ключом. Это не пара лишних строк — это разница между «работает» и «ваш ключ в репо у школьника».
// ПЛОХО: ключ зашит в клиентский компонент.
// Любой нажмёт F12 → Sources → увидит твой ключ.
// app/page.tsx (Client Component)
'use client';
export default function Page() {
const callApi = async () => {
// ↓ КЛЮЧ ЛЕТИТ В БРАУЗЕР, ЕГО ВИДНО ВСЕМ
await fetch('https://api.openai.com/v1/chat/completions', {
headers: { Authorization: 'Bearer sk-proj-...' },
});
};
return <button onClick={callApi}>Спросить ИИ</button>;
}
// ХОРОШО: ключ остаётся на сервере, клиент дёргает свой роут.
// app/api/ask/route.ts (серверная функция)
export async function POST(req: Request) {
const { prompt } = await req.json();
const r = await fetch('https://api.openai.com/v1/chat/completions', {
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
}),
});
return Response.json(await r.json());
}
// app/page.tsx — клиент дёргает СВОЙ /api/ask, не OpenAI напрямую
'use client';
export default function Page() {
const ask = async () => {
await fetch('/api/ask', {
method: 'POST',
body: JSON.stringify({ prompt: 'Привет' }),
});
};
return <button onClick={ask}>Спросить</button>;
}Антипаттерн vs правильный подход — серверный роут как прокси