Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Изоляция серверного кода в Next.js: как защитить секреты с помощью server-only и TypeScript

Тимофей Ищенко

Коротко: Разбираем изоляцию серверной логики в 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 переменные окружения разделены на две группы:

  1. Публичные переменные (с префиксом NEXT_PUBLIC_): На этапе сборки (next build) компилятор находит все вхождения process.env.NEXT_PUBLIC_* и заменяет их статическими строковыми значениями прямо в JavaScript-файлах клиента (inlining). Эти значения открыты: любой пользователь может увидеть их в исходном коде через DevTools. Здесь допустимо хранить только неконфиденциальные данные: публичные идентификаторы аналитики или базовые URL публичных API.

  2. Приватные переменные (без префикса): Доступны исключительно в среде выполнения сервера (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> : {});

Преимущества подхода

  1. Раннее обнаружение ошибок (Fail-Fast): сервис завершает работу с ошибкой сразу при старте, если отсутствует обязательный ключ.
  2. Строгая типизация и автодополнение: IDE подсказывает доступные поля и типы данных.
  3. Валидация формата: проверка валидности 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 — позволяет исключить риск утечки приватных ключей еще на этапе сборки проекта.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также