На статье этого блога HTML весит 189 КБ, и 112 КБ из них — не разметка, а RSC payload: служебные данные React, встроенные в страницу скриптами. Каждый абзац текста лежит в HTML дважды. Так устроен App Router, и пока в payload едет только то, что видно на странице, это нормально. Проблемы начинаются, когда в него попадает то, что пользователь не видит: полный объект модели, переданный в кнопку лайка.
Отсюда вопрос, который задают почти на каждом проекте с внешним API: если на сервере я получаю модель Article целиком, а клиентским компонентам отдаю только нужные поля — это правильно? Или нужно сразу просить у API только нужные поля?
Ответ: это две разные границы с разной ценой ошибки. Граница «сервер → браузер» определяет, что увидит и скачает пользователь, и следить за ней нужно всегда. Граница «API → сервер» влияет на память, кэш и задержку, и резать там стоит только при определённых условиях. Ниже — как различать эти случаи и как устроить код, чтобы лишнее не утекало в браузер случайно.
Что такое RSC payload
RSC payload — это сериализованное представление дерева React Server Components, которое сервер Next.js отправляет в браузер вместе с HTML и при каждой клиентской навигации. Внутри три вещи: результат рендера серверных компонентов, ссылки на JS-файлы клиентских компонентов и все props, переданные из серверного компонента в клиентский.
Последний пункт и есть ответ на половину вопроса. Серверный компонент отправляет в браузер только то, что отрендерил. Клиентский компонент получает свои props целиком — даже поля, которые он не читает.
Версия. Примеры проверены на Next.js 16.3 с App Router. В Pages Router механика другая: там клиенту уходит весь объект, который вернул getServerSideProps, и выборка полей на сервере обязательна.
Как данные из API попадают в браузер
У данных три канала, и каждый оптимизируется отдельно:
Внешний API ──(1) JSON модели──▶ Сервер Next.js
│
├─ серверные компоненты: рендер
│
└─(2) RSC payload ──▶ Браузер
├─ отрендеренное дерево
└─ props клиентских компонентов
Сборка ──(3) JS-бандл ──▶ Браузер
код "use client" и всё, что он импортирует- Ответ API живёт только на сервере. В браузер он не попадает, если вы сами его туда не передали. Его размер влияет на сеть между сервером и API, память процесса Node.js и объём кэша.
- RSC payload скачивает пользователь. На первой загрузке он встроен в HTML, при навигации по ссылкам загружается отдельно, а ссылки в зоне видимости
<Link>подгружает заранее. Лишние килобайты в props повторяются на каждом переходе. - JS-бандл от данных не зависит вообще. Его размер определяет граница
"use client": всё, что импортирует файл с этой директивой, попадает в клиентский граф модулей.
Смешивать эти каналы — главная ошибка в рассуждениях про «лёгкие компоненты». Выборка полей в API не уменьшает бандл, а вынос логики на сервер не уменьшает payload, если результат всё равно передаётся в props.
Корректно ли брать всю модель на сервере и отдавать клиенту только нужные поля?
Да. Это основной и правильный паттерн App Router. Разница видна на одном компоненте.
Так в payload уходит весь объект: текст статьи, SEO-поля, автор, связанные товары:
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getArticle } from "@/lib/api/articles";
import { LikeButton } from "@/components/LikeButton";
export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) notFound();
return (
<article>
<h1>{article.title}</h1>
{/* Сериализуется весь article, хотя кнопке нужны два поля */}
<LikeButton article={article} />
</article>
);
}Так — только то, что нужно кнопке:
// app/blog/[slug]/page.tsx — фрагмент
<LikeButton articleId={article.id} initialLikes={article.likes} />// components/LikeButton.tsx
"use client";
import { useState } from "react";
interface LikeButtonProps {
articleId: number;
initialLikes: number;
}
export function LikeButton({ articleId, initialLikes }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
return (
<button data-article={articleId} onClick={() => setLikes((n) => n + 1)}>
♥ {likes}
</button>
);
}Заголовок и текст статьи при этом рендерит серверный компонент: они попадут в payload как готовая разметка, но без полей, которые нигде не выводятся.
Тип Pick не защищает от утечки полей. Частая «защита» — объявить prop как article: Pick<Article, "id" | "likes">. TypeScript проверяет лишние свойства только у объектных литералов, поэтому <LikeButton article={article} /> с полной моделью компилируется без ошибок. React же сериализует объект целиком, включая поля, которых нет в типе. Сборка зелёная, а в исходном коде страницы лежат email автора и внутренние заметки редактора. Защищают только примитивы в props или явно собранный новый объект.
Когда нужно сразу запрашивать у API только нужные поля
Выборка на стороне API не влияет на то, что скачает пользователь, если граница с клиентом уже под контролем. Она нужна по другим причинам.
Списки. Модель умножается на количество элементов. Детальная статья весит 21 КБ JSON, а в списке на 50 позиций это уже мегабайт, из которого карточке нужны заголовок, дата и картинка. Сервер Next.js скачивает, парсит и держит в памяти всё это на каждом запросе без кэша.
Кэширование. Next.js не сохраняет в кэш данных fetch-ответы больше 2 МБ. В логе сервера это выглядит как Failed to set fetch cache ... items over 2MB can not be cached, и страница, которая должна была отдаваться из кэша, начинает ходить в API на каждый запрос. Записи "use cache" тоже хранят сериализованный результат функции: чем меньше объект, тем больше записей помещается в память.
Тяжёлые связи. Если API для полной модели собирает связанные сущности (автора, теги, товары), отказ от лишних связей сокращает время ответа самого API — часто это заметнее, чем экономия трафика.
Если API поддерживает выборку полей (GraphQL, sparse fieldsets из JSON:API, select в REST Битрикс24, fields в Strapi), список стоит запрашивать так:
// lib/api/articles.ts — фрагмент
const CARD_FIELDS = ["slug", "title", "excerpt", "publishedAt", "cover"] as const;
export type ArticleCardSource = Pick<ApiArticle, (typeof CARD_FIELDS)[number]>;
export async function getArticleCards(page: number): Promise<ArticleCardDTO[]> {
// Имя параметра зависит от API: fields, select, fields[articles]
const query = new URLSearchParams({
page: String(page),
limit: "20",
fields: CARD_FIELDS.join(","),
});
const res = await fetch(`${API_URL}/articles?${query}`, { headers: authHeaders() });
if (!res.ok) throw new Error(`Content API ${res.status}: список статей, страница ${page}`);
const { items } = (await res.json()) as { items: ArticleCardSource[] };
return items.map(toArticleCard);
}Обратите внимание: даже после выборки результат проходит через toArticleCard. Выборка полей в API экономит ресурсы сервера, а маппер задаёт контракт с клиентом. Это разные обязанности, и одна не заменяет другую.
Когда выборка не нужна. На детальной странице, где используется большая часть модели, и когда API выборку не поддерживает. Не стоит дробить один запрос на три узких под разные компоненты: один полный запрос, обёрнутый в cache из React, выполнится один раз за рендер, а три узких — три раза.
Слой доступа к данным: DTO под каждый сценарий
Чтобы правило «клиенту — только нужное» не зависело от внимательности на код-ревью, его выносят в отдельный слой. Документация Next.js называет его Data Access Layer: модуль, который выполняется только на сервере, читает секреты из окружения и возвращает минимальные объекты под конкретный сценарий (DTO).
Тип ответа API описывает модель такой, какой её отдаёт бэкенд, со всеми полями:
// lib/api/types.ts
export interface ApiArticle {
id: number;
slug: string;
title: string;
excerpt: string | null;
body: string;
publishedAt: string;
likes: number;
cover: { url: string; width: number; height: number } | null;
author: { name: string; email: string; phone: string | null };
internalNotes: string | null;
}Запрос к API — модуль целиком, фрагмент со списком выше дописывается в него же:
// lib/api/articles.ts
import "server-only";
import { cache } from "react";
import type { ApiArticle } from "./types";
const API_URL = process.env.CONTENT_API_URL;
export function authHeaders() {
return { Authorization: `Bearer ${process.env.CONTENT_API_TOKEN}` };
}
// cache() схлопывает повторные вызовы в пределах одного рендера:
// generateMetadata и страница получат один и тот же запрос
export const getArticle = cache(async (slug: string): Promise<ApiArticle | null> => {
const res = await fetch(`${API_URL}/articles/${encodeURIComponent(slug)}`, {
headers: authHeaders(),
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Content API ${res.status}: статья ${slug}`);
return res.json();
});import "server-only" превращает случайный импорт этого модуля в клиентский компонент в ошибку сборки. Без него функция подтянулась бы в бандл, а токен из process.env заменился бы пустой строкой — и вы получили бы 401 в браузере вместо понятной ошибки при сборке.
Мапперы DTO:
// lib/api/article-dto.ts
import "server-only";
import type { ApiArticle } from "./types";
import type { ArticleCardSource } from "./articles";
export interface ArticleCardDTO {
slug: string;
title: string;
excerpt: string;
publishedAt: string;
coverUrl: string | null;
}
export function toArticleCard(a: ArticleCardSource): ArticleCardDTO {
return {
slug: a.slug,
title: a.title,
excerpt: a.excerpt ?? "",
publishedAt: a.publishedAt,
coverUrl: a.cover?.url ?? null,
};
}
export function toLikeProps(a: ApiArticle) {
return { articleId: a.id, initialLikes: a.likes };
}На странице это выглядит как <LikeButton {...toLikeProps(article)} />: компонент получает два числа, и никакая правка модели в API этого не изменит.
Маппер — это белый список. Соблазнительная альтернатива — чёрный список через деструктуризацию: const { internalNotes, ...rest } = article; return rest;. Она работает ровно до момента, когда бэкенд добавит в модель поле editorComment или author.phone. Оно молча уедет в браузер, и узнаете вы об этом последним. Белый список при новом поле не делает ничего — и это правильное поведение по умолчанию.
Сами типы DTO можно импортировать в клиентские компоненты через import type: такой импорт стирается при компиляции и не тянет server-only в клиентский граф.
Taint API как второй рубеж. С experimental.taint: true в next.config.ts можно пометить объект через experimental_taintObjectReference, и React выбросит ошибку при попытке передать его клиенту. Но пометка привязана к ссылке: копия { ...article } уже не защищена. Документация прямо называет это дополнительным слоем, а не заменой DTO.
Как уменьшить JS-бандл, а не только payload
Props не влияют на бандл. На него влияет то, что лежит за "use client". Три приёма дают основной эффект.
Форматировать на сервере. Если клиентский компонент получает сырые данные и форматирует их сам — markdown через marked, даты через date-fns, цены через самописный хелпер со справочником валют, — все эти библиотеки уезжают в бандл. Отформатируйте на сервере и передайте строку.
Опускать "use client" в листья. Директива нужна компоненту, где есть состояние или обработчик, а не всей карточке вокруг него. Карточка товара остаётся серверной, клиентской становится только кнопка «В корзину».
Передавать серверное содержимое через children. Клиентская обёртка с состоянием не обязана импортировать то, что показывает:
// app/products/[id]/page.tsx — фрагмент
<Collapsible title="Характеристики">
{/* Серверный компонент: рендерится на сервере, в бандл не попадает */}
<SpecsTable specs={product.specs} />
</Collapsible>Collapsible хранит только флаг «открыто/закрыто». SpecsTable с таблицей на 200 строк приходит в payload готовой разметкой, а её код в браузер не загружается.
Почему в HTML страницы данные дублируются?
Потому что HTML нужен для мгновенного отображения, а payload — для того, чтобы React сверил дерево и оживил клиентские компоненты. Текст статьи, отрендеренный серверным компонентом, присутствует в обоих местах. Проверка на статье этого блога: уникальная фраза из текста встречается в HTML дважды, один раз внутри self.__next_f.push.
Отсюда неочевидный вывод: серверный компонент экономит JavaScript, но не байты в HTML. Список из 500 строк, отрендеренный на сервере, всё равно попадёт в страницу дважды. Если список длинный, помогает пагинация или подгрузка по скроллу, а не перенос рендера между сервером и клиентом.
Перенос того же списка в клиентский компонент дешевле не сделает. Клиентские компоненты тоже пререндерятся на сервере, поэтому HTML-разметка списка в странице останется. К ней добавится массив данных в payload и код компонента в бандле. Клиентский рендер списка оправдан, только когда его фильтруют и сортируют без запроса к серверу.
Отладка: как найти лишние данные в payload
| Симптом | Причина | Проверка |
|---|---|---|
| Простая страница весит 300+ КБ HTML | В props клиентского компонента передана модель или массив моделей | Доля payload в HTML, команда ниже |
| В исходном коде страницы видны email, телефоны, внутренние поля | Объект передан целиком или через ...rest | grep по имени поля |
Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported. | В props передан экземпляр класса из SDK API | Преобразовать в DTO-литерал |
В логе items over 2MB can not be cached | Кэшируется полный ответ списка | Выборка полей в API или маппинг до кэша |
| Бандл вырос после «небольшой» правки компонента | В файл с "use client" добавлен импорт библиотеки | next experimental-analyze |
Доля payload в HTML первой загрузки:
curl -s https://example.ru/blog/some-article/ > page.html
wc -c < page.html # весь HTML
grep -o 'self.__next_f.push([^<]*' page.html | wc -c # из них RSC payload
grep -c 'internalNotes\|passwordHash\|"email"' page.html # поля, которых быть не должноПри клиентской навигации payload приходит отдельными запросами: в DevTools на вкладке Network отфильтруйте по _rsc и посмотрите размер ответов. Если переход на карточку товара скачивает 400 КБ, а на экране три абзаца и цена, — ищите, какой клиентский компонент получил модель целиком.
Для бандла в Next.js 16 таблица First Load JS из вывода next build убрана: разработчики признали её неточной для серверных компонентов. Начиная с 16.1, вместо неё есть анализатор для Turbopack:
npx next experimental-analyzeОн показывает клиентский граф модулей с цепочками импортов — видно, через какой файл с "use client" в бандл попала библиотека.
Результаты: сколько весят данные на этом блоге
Замер на реальном контенте блога: 146 статей, JSON тех наборов полей, которые можно было бы передать клиентскому компоненту списка.
| Что передаётся | JSON | gzip |
|---|---|---|
| Статьи целиком с текстом | 3317 КБ | 817 КБ |
| Только фронтматтер (с FAQ и коротким ответом) | 893 КБ | 218 КБ |
| Превью для карточки: 10 полей | 80,6 КБ | 19,1 КБ |
| Ссылка: slug, заголовок, категория | 21,5 КБ | 6,3 КБ |
Разница между первой и третьей строкой — 40 раз. Список /blog/ здесь серверный и получает по 12 превью на страницу: 50 КБ HTML, из них 32 КБ — payload. Но в блоге есть и сценарий, где клиентскому компоненту нужны все статьи сразу, — поиск на Fuse.js, который ищет по массиву в браузере. С превью это 80 КБ в payload каждой страницы, со статьями целиком было бы 3,3 МБ. Первая строка к тому же больше лимита кэша fetch в 2 МБ: такой ответ API Next.js не закэшировал бы вовсе.
Те же правила работают и для headless-связки, где внешний API — это Битрикс; подробности про сам API и кэш — в статье про Next.js как фронтенд для Битрикса. Как кэшировать ответы на сервере, разобрано в материале про кэширование в Next.js, а если данные всё-таки нужно запрашивать из браузера — в статье про React Query в Next.js.
Итоги
- Брать модель целиком в серверном компоненте и передавать клиентским компонентам только нужные поля — правильно. Сырой ответ API в браузер не попадает.
- Клиентский компонент получает props целиком. Защищают примитивы и явно собранные объекты, а не тип
Pick. - Маппер DTO — белый список полей. Деструктуризация с
...rest— чёрный список, и он протекает при первом же новом поле в API. - Выборку полей в API стоит делать для списков, кэшируемых данных и тяжёлых связей. Для детальной страницы с полной моделью она не нужна.
- Бандл зависит от того, что импортировано за
"use client", а не от данных. Форматируйте на сервере, держите директиву в листьях дерева.
И последнее, менее очевидное: перенос рендера на сервер не уменьшает размер HTML. Серверный компонент экономит JavaScript, но его результат всё равно лежит в странице дважды. Если страница тяжёлая из-за объёма контента, а не из-за кода, помогает пагинация, а не замена "use client" на серверный компонент.
Частые вопросы
Можно ли в Next.js получать всю модель из API в page.tsx, а клиентским компонентам передавать только нужные поля?
Да, это правильный подход. Серверный компонент выполняется только на сервере, и сырой ответ API в браузер не попадает. Клиенту уходят две вещи: отрендеренный результат серверных компонентов и props клиентских компонентов в RSC payload. Поэтому контролировать нужно именно то, что передаётся в props компонентов с директивой "use client".
Нужно ли запрашивать у API только необходимые поля в серверных компонентах Next.js?
На размер страницы в браузере это не влияет, если клиентским компонентам и так передаются только нужные поля. Выборка полей на стороне API экономит трафик и время между сервером Next.js и API, память процесса и место в кэше данных. Это стоит делать для списков, где модель умножается на 20–100 элементов, и для данных, которые кэшируются: Next.js не сохраняет в кэш fetch-ответы больше 2 МБ.
Что попадает в RSC payload в Next.js?
Отрендеренное дерево серверных компонентов, ссылки на JS-файлы клиентских компонентов и все props, переданные из серверного компонента в клиентский. Payload встраивается в HTML первой загрузки скриптами self.__next_f.push и загружается отдельными запросами при клиентской навигации и префетче ссылок.
Почему HTML страницы на Next.js весит в два раза больше, чем ожидалось?
Потому что контент страницы присутствует в нём дважды: один раз в виде HTML-разметки и второй раз в RSC payload, который нужен React для сверки дерева и гидратации. На статье этого блога из 189 КБ HTML 112 КБ занимает payload. Это нормальное поведение, но каждый лишний байт в props клиентских компонентов попадает в страницу сверх видимого текста.
Защищает ли тип Pick от передачи лишних полей в клиентский компонент?
Нет. TypeScript проверяет лишние свойства только у объектных литералов, поэтому объект модели целиком проходит проверку типа Pick<Article, "id" | "likes">. При этом React сериализует объект полностью, со всеми полями, которые компонент даже не читает. Защищает только явное построение нового объекта или передача примитивов.
Когда не нужно ограничивать поля в запросе к API?
На детальной странице, где используется большая часть модели, и когда API не поддерживает выборку полей. Строить обходные решения ради экономии нескольких килобайт между сервером и API не стоит. Также не нужно дробить один запрос на несколько узких: один полный запрос, обёрнутый в React cache, дешевле трёх запросов с разными наборами полей.