server-only и данные, текущие вниз
Граница сервер/клиент — ещё и граница безопасности: помечай модули с секретами и доступом к данным через import "server-only", чтобы клиентский импорт ронял сборку, грузи данные на сервере и передавай вниз только простые сериализуемые данные клиентским листьям.
Серверный компонент может await db.query(...), прочитать process.env.STRIPE_SECRET_KEY и вызвать внутренний сервис с админ-токеном — и ничего из этого кода не уезжает в браузер. В этом весь смысл границы сервер/клиент: часть твоего дерева исполняется там, где живут секреты, а остальное — на ноутбуке незнакомца.
Опасность в том, что граница держится на одной директиве ("use client") и нескольких правилах импорта, и один неосторожный импорт затаскивает драйвер базы данных — или приватный ключ — прямиком в клиентский бандл. Этот урок о том, чтобы относиться к границе как к тому, чем она и является: к границе безопасности, со стражем на этапе сборки (import "server-only"), чтобы нарушение роняло CI, а не утекало учётной записью к каждому посетителю.
После этого урока ты можешь пометить модуль с секретом или доступом к данным через import "server-only", чтобы любой клиентский импорт ронял сборку; устроить фичу как серверный компонент, который загружает данные и передаёт простые, сериализуемые данные вниз интерактивному клиентскому ребёнку; объяснить, почему раздел сервер/клиент — это граница безопасности, а не только производительности; и распознать два режима отказа, утекающих учётные данные, — секрет с префиксом NEXT_PUBLIC_ и прямой импорт db/секрета, доходящий до клиентского компонента.
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 делает такую ошибку неотгружаемой.
Загружай данные на сервере; серверный компонент — это граница данных. Серверный компонент — 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 не наблюдаемо из браузера.
Передавай вниз простые, сериализуемые данные — пропсы — это 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>
);
}Ментальная модель: секреты и доступ остаются наверху, у серверного корня; простые данные текут вниз к интерактивным листьям. Клиентскому компоненту позволено быть интерактивным именно потому, что у него никогда не было возможности, которую стоит защищать, — он всегда видел только урезанный результат.
Режим отказа: секрет 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.