open atlas
↑ К треку
Паттерны React RXP · 10 · 03

server-only и данные, текущие вниз

Граница сервер/клиент — ещё и граница безопасности: помечай модули с секретами и доступом к данным через import "server-only", чтобы клиентский импорт ронял сборку, грузи данные на сервере и передавай вниз только простые сериализуемые данные клиентским листьям.

RXP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Серверный компонент может await db.query(...), прочитать process.env.STRIPE_SECRET_KEY и вызвать внутренний сервис с админ-токеном — и ничего из этого кода не уезжает в браузер. В этом весь смысл границы сервер/клиент: часть твоего дерева исполняется там, где живут секреты, а остальное — на ноутбуке незнакомца.

Опасность в том, что граница держится на одной директиве ("use client") и нескольких правилах импорта, и один неосторожный импорт затаскивает драйвер базы данных — или приватный ключ — прямиком в клиентский бандл. Этот урок о том, чтобы относиться к границе как к тому, чем она и является: к границе безопасности, со стражем на этапе сборки (import "server-only"), чтобы нарушение роняло CI, а не утекало учётной записью к каждому посетителю.

Цель

После этого урока ты можешь пометить модуль с секретом или доступом к данным через import "server-only", чтобы любой клиентский импорт ронял сборку; устроить фичу как серверный компонент, который загружает данные и передаёт простые, сериализуемые данные вниз интерактивному клиентскому ребёнку; объяснить, почему раздел сервер/клиент — это граница безопасности, а не только производительности; и распознать два режима отказа, утекающих учётные данные, — секрет с префиксом NEXT_PUBLIC_ и прямой импорт db/секрета, доходящий до клиентского компонента.

1

import "server-only" превращает утечку в ошибку сборки — применяй его к каждому модулю, который касается секрета или источника данных. Пакет server-only не экспортирует ничего полезного; его задача — уронить бандлер, если модуль оказался в клиентском графе. Ставь его в начало всего, что читает env-секреты, открывает соединение с БД или держит приватный ключ.

// lib/db.ts
import "server-only"; // ← если клиентский компонент импортирует это, сборка падает

import { Pool } from "pg";

// Никогда не достижимо из браузерного бандла: импорт выше это гарантирует.
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function getUser(id: string) {
  const { rows } = await pool.query(
    "select id, name, plan from users where id = $1",
    [id],
  );
  return rows[0] ?? null;
}

Без стража этот модуль работает — до того дня, когда кто-нибудь импортирует getUser (или хелпер, транзитивно его подтягивающий) из файла с "use client". Тогда драйвер pg, строка подключения и запрос — всё попадает в бандл и отдаётся. server-only делает такую ошибку неотгружаемой.

2

Загружай данные на сервере; серверный компонент — это граница данных. Серверный компонент — async, исполняется только на сервере и никогда не перерисовывается на клиенте, поэтому именно ему и принадлежит доступ к данным. Он напрямую вызывает server-only-модуль и порождает разметку страницы.

// app/users/[id]/page.tsx  — серверный компонент (без "use client")
import { getUser } from "~/lib/db";
import { PlanBadge } from "./PlanBadge"; // клиентский лист, ниже

export default async function UserPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const user = await getUser(id); // исполняется на сервере, с секретом
  if (!user) return <p>Not found.</p>;

  // Границу пересекают только нужные UI поля — не вся строка,
  // не соединение, не токен.
  return (
    <main>
      <h1>{user.name}</h1>
      <PlanBadge name={user.name} plan={user.plan} />
    </main>
  );
}

Серверный компонент держит опасные возможности (пул, env-переменную) и отдаёт клиенту только то, что тому нужно отрисовать. Ничего из внутренностей getUser не наблюдаемо из браузера.

3

Передавай вниз простые, сериализуемые данные — пропсы — это API границы. Всё, что ты отдаёшь клиентскому ребёнку, сериализуется и отправляется по проводу. Поэтому передавай примитивы, простые объекты и массивы — не экземпляры классов, не клиент БД, не функцию, замыкающую секрет. Клиентский компонент — это лист, который получает готовые данные и добавляет интерактивность.

// app/users/[id]/PlanBadge.tsx
"use client";

import { useState } from "react";

// Получает только то, что рисует — уже загруженное, уже урезанное.
export function PlanBadge({ name, plan }: { name: string; plan: string }) {
  const [open, setOpen] = useState(false);
  return (
    <button onClick={() => setOpen((v) => !v)}>
      {plan}
      {open && <span> — {name} is on the {plan} plan</span>}
    </button>
  );
}

Ментальная модель: секреты и доступ остаются наверху, у серверного корня; простые данные текут вниз к интерактивным листьям. Клиентскому компоненту позволено быть интерактивным именно потому, что у него никогда не было возможности, которую стоит защищать, — он всегда видел только урезанный результат.

4

Режим отказа: секрет NEXT_PUBLIC_ или прямой импорт db в клиентском компоненте — настоящая утечка учётных данных. Две конкретные ошибки утекают продакшен-секреты к каждому посетителю, и обе легко допустить:

// ОТКАЗ 1 — префикс, отправляющий секрет в браузер.
// Всё NEXT_PUBLIC_* ВСТРАИВАЕТСЯ в клиентский JS на этапе сборки.
"use client";
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!); // 🔴 теперь публичный
// «Заработало», потому что значение было достижимо — вот и баг. Просмотр
// исходника его раскрывает. Лекарство — НИКОГДА не давай секрету префикс NEXT_PUBLIC_.

// ОТКАЗ 2 — затаскивание серверного модуля в клиентский граф.
"use client";
import { getUser } from "~/lib/db"; // 🔴 тянет pg + DATABASE_URL в бандл
//   — если в lib/db.ts есть `import "server-only"`, эта строка роняет СБОРКУ, а не
//   молча отгружает драйвер и строку подключения. В этом страже весь смысл.

NEXT_PUBLIC_ — для значений, которые должны быть публичными (опубликованный базовый URL API, ключ аналитики, рассчитанный на клиент). Секретный ключ с этим префиксом не «настроен» — он опубликован. А server-only существует именно для того, чтобы ОТКАЗ 2 был красной строкой в CI, а не разбором инцидента. Когда не стоит загружать на сервере вовсе: данные, привязанные к взаимодействию и принадлежащие клиенту (поиск с debounce, который пользователь печатает), по-прежнему живут в клиенте — но он должен вызывать route handler / server action, никогда не БД напрямую.

Разбор примера

Панель пользовательских настроек: вынь секрет из клиента, спусти данные вниз. «До» — это единственный компонент с "use client", который загружает данные с ключом, встроенным в бандл, — он рисуется, он «работает» и отгружает живой ключ Stripe в каждый браузер.

// ДО — секрет в клиенте, логика db в клиенте. 🔴
"use client";
import { useEffect, useState } from "react";
import Stripe from "stripe";

const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!); // встроено!

export function Billing({ userId }: { userId: string }) {
  const [sub, setSub] = useState<any>(null);
  useEffect(() => {
    stripe.subscriptions.list({ customer: userId }).then((r) => setSub(r.data[0]));
  }, [userId]);
  return <p>Plan: {sub?.status ?? "…"}</p>;
}

«После» разделяет вдоль границы. server-only-модуль владеет ключом, серверный компонент загружает с ним, а крошечный клиентский лист получает простые поля и добавляет интерактивную часть.

// lib/billing.ts
import "server-only";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // без NEXT_PUBLIC_

export async function getPlan(userId: string) {
  const { data } = await stripe.subscriptions.list({ customer: userId });
  return { status: data[0]?.status ?? "none" }; // простой объект, ничего секретного
}

// app/billing/page.tsx — серверный компонент
import { getPlan } from "~/lib/billing";
import { PlanToggle } from "./PlanToggle";
export default async function Billing({ userId }: { userId: string }) {
  const plan = await getPlan(userId);          // секрет остаётся на сервере
  return <PlanToggle status={plan.status} />;  // границу пересекает только "active"/"none"
}

// app/billing/PlanToggle.tsx — клиентский лист
("use client");
import { useState } from "react";
export function PlanToggle({ status }: { status: string }) {
  const [show, setShow] = useState(false);
  return <button onClick={() => setShow((v) => !v)}>{show ? status : "details"}</button>;
}

Изменилась не структура ради структуры: ключ Stripe теперь существует только в памяти сервера, импорт server-only превращает регрессию в ошибку сборки, а браузер получает единственную строку ("active"). Тот же UI — но учётные данные ни разу не покинули сервер.

Почему это работает

Зачем import "server-only" стоит отдельной зависимости, если правило «просто не импортируй это в клиенте» и так есть? Потому что правило невидимо и транзитивно. Утилита вроде formatPlan импортирует getPlan, клиентский компонент импортирует formatPlan ради одного хелпера — и вот драйвер БД уже в бандле; никто не писал import { getUser } в файле с "use client", так что ревью это упускает. server-only делает граф стражем: если server-only-модуль достижим из любой клиентской точки входа, бандлер останавливается. Он превращает дисциплину, которую надо помнить на каждом PR, в инвариант, который сборка проверяет бесплатно.

Частая ошибка

Относиться к NEXT_PUBLIC_ как к «способу настроить фронтенд» и хвататься за него всякий раз, когда клиентскому компоненту нужно значение. У префикса ровно одно значение: встрой это в клиентский бандл и отдай всем подряд. Это правильно для публичного базового URL API и катастрофично для секрета. Признак — спроси «нужно ли клиенту отправлять это третьей стороне?»: если да (секретный ключ Stripe, пароль БД, админ-токен), оно никогда не может быть NEXT_PUBLIC_; запрос должен зарождаться на сервере. Клиент может держать публикуемый ключ, но никогда — секретный.

Проверь себя
Викторина

Клиентскому компоненту нужно показать статус подписки пользователя. Коллега добавляет STRIPE_SECRET_KEY как NEXT_PUBLIC_STRIPE_SECRET_KEY, чтобы клиент мог вызывать Stripe напрямую, и в dev это работает. Почему это провал безопасности и какова правильная форма?

Итог

Граница сервер/клиент — это граница безопасности, а не только производительности: над ней исполняются секреты и доступ к данным, под ней — UI в недоверенной среде. Помечай каждый модуль с секретом или доступом к данным через import "server-only", чтобы клиентский импорт ронял сборку, а не молча тащил в бандл драйвер или ключ. Делай загрузку в серверном компоненте, где живёт секрет, и передавай вниз только простые, сериализуемые данные интерактивным клиентским листьям — пропсы — это API границы, поэтому никогда не отправляй клиент БД, функцию, замыкающую секрет, или всю строку целиком. Оба режима отказа утекают настоящие учётные данные: секрет с префиксом NEXT_PUBLIC_ (встроенный в клиентский JS на всеобщее обозрение) и прямой импорт db/секрета в клиентском компоненте (который server-only превращает в красную строку сборки). Рефлекс уровня senior: держи возможности наверху, пусти данные течь вниз и пусть утечку ловит сборка, а не код-ревью.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.