Коротко: Разбираем изоляцию серверной логики в Next.js App Router: защита секретов пакетом server-only, типизация env с Zod и архитектура Data Access Layer.
Архитектура React Server Components (RSC) в Next.js App Router стерла явную физическую границу между серверным и клиентским кодом. Возможность выполнять запросы к базам данных и сторонним API прямо внутри дерева компонентов значительно ускорила разработку, но одновременно создала риски утечки конфиденциальных данных. Случайный импорт модуля с приватным ключом или серверной логикой в компонент с директивой 'use client' может привести к попаданию секретов в публичный бандл браузера.
Надежная защита серверного контекста строится на трех уровнях: аппаратная изоляция на этапе сборки с помощью пакета server-only, строгая валидация и типизация окружения через TypeScript и Zod, а также организация изолированного слоя доступа к данным (Data Access Layer).
1. Механика окружения в Next.js: где живут переменные
Корректное разделение переменных окружения — фундаментальный элемент безопасности любого fullstack-приложения.
NEXT_PUBLIC_ против приватного process.env
В Next.js переменные окружения разделены на две группы:
Публичные переменные (с префиксом
NEXT_PUBLIC_): На этапе сборки (next build) компилятор находит все вхожденияprocess.env.NEXT_PUBLIC_*и заменяет их статическими строковыми значениями прямо в JavaScript-файлах клиента (inlining). Эти значения открыты: любой пользователь может увидеть их в исходном коде через DevTools. Здесь допустимо хранить только неконфиденциальные данные: публичные идентификаторы аналитики или базовые URL публичных API.Приватные переменные (без префикса): Доступны исключительно в среде выполнения сервера (Node.js или Edge Runtime). Они считываются в Server Components, Route Handlers (
app/api/...) и Server Actions. В клиентский бандл эти значения не передаются.
Анатомия утечки: как приватный код оказывается на клиенте
Основная причина утечек — объединение серверной логики и вспомогательных утилит в общие модули:
// ❌ lib/api.ts — смешанный модуль с уязвимостью
export const API_SECRET_KEY = process.env.PAYMENT_GATEWAY_SECRET;
export function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
Если клиентский компонент импортирует только функцию форматирования:
'use client';
import { formatPrice } from '@/lib/api';
export function PriceTag({ amount }: { amount: number }) {
return <span>{formatPrice(amount)}</span>;
}
Сборщик Next.js включает весь файл lib/api.ts в дерево зависимостей клиентского бандла. Хотя переменная process.env.PAYMENT_GATEWAY_SECRET на клиенте вернет undefined, сам факт размытия границ модулей создает угрозу: в клиентский код могут попасть служебные алгоритмы, приватные типы или побочные эффекты модулей.
2. Пакет server-only: аппаратный барьер сборщика
Чтобы исключить попадание серверного кода на сторону клиента из-за человеческого фактора, команда Next.js создала инструмент принудительной изоляции — пакет server-only.
Принцип работы import 'server-only'
Установка пакета:
npm install server-only
Добавление импорта в начало файла превращает его в закрытый серверный модуль:
// lib/payments.ts
import 'server-only';
const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY;
export async function processPayment(orderId: string) {
// Закрытая серверная логика
}
Поведение сборщика при нарушении границ
Если разработчик случайно импортирует lib/payments.ts в компонент с директивой 'use client', компилятор Next.js прервет сборку (next build или процесс в Dev-режиме) с явной ошибкой:
Error: You're importing a component that needs "server-only".
That only works in a Server Component but one of its parents is marked with "use client", so it's only looking in the Client Component.
Это гарантирует fail-fast поведение: код с серверной логикой не попадет в продакшен-бандл.
3. Типобезопасная конфигурация через TypeScript и Zod
По умолчанию process.env возвращает тип string | undefined. Из-за этого опечатки в названиях ключей не отслеживаются компилятором, а отсутствие обязательной переменной в .env обнаруживается только во время работы приложения при падении конкретного запроса.
Паттерн безопасного модуля env.ts
Для решения проблемы создается централизованный модуль конфигурации с валидацией схемы в момент инициализации:
// src/env.ts
import { z } from 'zod';
const serverSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().min(1),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
});
const clientSchema = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_ANALYTICS_ID: z.string().min(1),
});
const processEnv = {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
};
const isServer = typeof window === 'undefined';
const parsedServer = isServer ? serverSchema.safeParse(processEnv) : { success: true, data: {} };
const parsedClient = clientSchema.safeParse(processEnv);
if (!parsedClient.success) {
console.error('❌ Ошибка клиентских переменных окружения:', parsedClient.error.flatten().fieldErrors);
throw new Error('Некорректная конфигурация клиентского окружения');
}
if (isServer && !parsedServer.success) {
console.error('❌ Ошибка серверных переменных окружения:', parsedServer.error.flatten().fieldErrors);
throw new Error('Некорректная конфигурация серверного окружения');
}
export const env = {
...(parsedClient.data as z.infer<typeof clientSchema>),
...(isServer ? (parsedServer.data as z.infer<typeof serverSchema>) : {}),
} as z.infer<typeof clientSchema> & (typeof isServer extends true ? z.infer<typeof serverSchema> : {});
Преимущества подхода
- Раннее обнаружение ошибок (Fail-Fast): сервис завершает работу с ошибкой сразу при старте, если отсутствует обязательный ключ.
- Строгая типизация и автодополнение: IDE подсказывает доступные поля и типы данных.
- Валидация формата: проверка валидности URL, длины строк и значений перечислений до выполнения бизнес-логики.
4. Архитектурный паттерн: Data Access Layer (DAL)
Для надежной изоляции логики работы с базой данных и внешними API рекомендуется формировать выделенный слой доступа к данным (DAL).
app/
├── dashboard/
│ ├── page.tsx (Server Component)
│ └── ui/
│ └── MetricsView.tsx ('use client')
lib/
└── dal/
├── auth.ts (server-only)
└── metrics.ts (server-only)
Реализация защищенного слоя данных
// lib/dal/metrics.ts
import 'server-only';
import { env } from '@/src/env';
export interface SystemMetrics {
revenue: number;
activeUsers: number;
}
export async function fetchSystemMetrics(): Promise<SystemMetrics> {
const response = await fetch('https://api.internal.service/metrics', {
headers: {
Authorization: `Bearer ${env.STRIPE_SECRET_KEY}`,
},
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error('Ошибка получения метрик системы');
}
return response.json();
}
// app/dashboard/page.tsx (Server Component)
import { fetchSystemMetrics } from '@/lib/dal/metrics';
import { MetricsView } from './ui/MetricsView';
export default async function DashboardPage() {
// Выполняется строго на сервере
const metrics = await fetchSystemMetrics();
// Клиент получает только сериализованный DTO без доступа к секретам
return <MetricsView data={metrics} />;
}
Директива import 'server-only' внутри metrics.ts гарантирует, что импорт этой функции напрямую в клиентский MetricsView.tsx вызовет ошибку на этапе сборки.
5. Разграничение: 'use server' vs server-only
Директива 'use server' и пакет server-only выполняют принципиально разные функции, и их смешение приводит к архитектурным ошибкам.
| Критерий | import 'server-only' |
'use server' |
|---|---|---|
| Назначение | Полная изоляция серверного модуля от клиентского бандла. | Объявление публичной точки входа (Server Action). |
| Результат работы | Блокирует импорт файла в клиентские компоненты на этапе сборки. | Создает общедоступный HTTP-эндпоинт для вызова функции из браузера. |
| Уровень безопасности | Защищает внутреннюю логику и секреты от утечки. | Требует ручной валидации входных данных и проверки прав доступа. |
| Область применения | Data Access Layer, ORM-клиенты, криптография, закрытые утилиты. | Мутации данных, отправка форм, клиент-серверные действия. |
Директива 'use server' не скрывает функционал, а открывает его для внешнего сетевого взаимодействия. Закрытая бизнес-логика и конфиденциальные данные должны защищаться через server-only.
6. Чек-лист безопасности перед релизом
- [ ] Файлы
.env.localи локальные конфигурации добавлены в.gitignore. - [ ] Все модули, работающие с базами данных, токенами и приватными API, содержат
import 'server-only'. - [ ] Конфигурация окружения валидируется при старте приложения через схему (Zod).
- [ ] Переменные с префиксом
NEXT_PUBLIC_не содержат приватных данных и токенов авторизации. - [ ] Для всех Server Actions реализована валидация входных аргументов и проверка сессий.
- [ ] В CI/CD пайплайн встроен шаг
next buildдля предотвращения релизов с ошибочными импортами.
Часто задаваемые вопросы (FAQ)
Заменяет ли директива 'use server' пакет server-only?
Нет. 'use server' создает публичный RPC-эндпоинт для вызова функции из браузера. Пакет server-only выполняет противоположную задачу — полностью запрещает сборщику включать помеченный модуль в состав клиентского кода.
Что произойдет при импорте файла с server-only в клиентский компонент?
Сборка приложения завершится с ошибкой как на этапе выполнения next build, так и во время локальной разработки. Сборщик отобразит путь к файлу и компоненту, вызвавшему конфликт.
Зачем нужен пакет client-only?
Пакет client-only действует зеркально: он вызывает ошибку сборки, если модуль импортируется в Server Component. Это актуально для утилит, напрямую работающих с Web API браузера (window, localStorage, navigator), которые отсутствуют на сервере.
Достаточно ли убрать префикс NEXT_PUBLIC_ для защиты секрета?
Не всегда. Без префикса значение переменной не попадет в клиентский код в открытом виде, но если модуль с приватным process.env будет импортирован в клиентский компонент, сам код модуля и его структура окажутся в публичном бандле. server-only предотвращает такой импорт на этапе компиляции.
Как защитить секреты в Pages Router?
В Pages Router разделение базируется на структуре папок: код в pages/api/* и функциях getServerSideProps / getStaticProps выполняется только на сервере. Пакет server-only ориентирован на компонентную модель App Router, где клиентские и серверные модули могут находиться в одной директории.
Влияет ли использование server-only на размер бандла?
Да, положительно. Пакет предотвращает случайное попадание тяжелых серверных зависимостей (ORM, криптографические библиотеки, Node.js SDK) в клиентский бандл, сохраняя минимальный вес итоговых JS-файлов.
Заключение
Безопасность архитектуры в Next.js App Router должна обеспечиваться инфраструктурными инструментами, а не человеческим фактором. Применение подхода Secure by Default — изоляция модулей через server-only, выстраивание Data Access Layer и строгая валидация конфигураций с помощью TypeScript и Zod — позволяет исключить риск утечки приватных ключей еще на этапе сборки проекта.




.svg.webp)





