Next.js: полная модель из API или только нужные поля

Как данные из внешнего API доходят до браузера в Next.js App Router, что попадает в RSC payload и где на самом деле нужно резать модель.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 1 октября 2026 г.📖 13 мин чтения

На статье этого блога 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 попадают в браузер

У данных три канала, и каждый оптимизируется отдельно:

text
Внешний API ──(1) JSON модели──▶ Сервер Next.js
                                   │
                                   ├─ серверные компоненты: рендер
                                   │
                                   └─(2) RSC payload ──▶ Браузер
                                         ├─ отрендеренное дерево
                                         └─ props клиентских компонентов

Сборка ──(3) JS-бандл ──▶ Браузер
         код "use client" и всё, что он импортирует
  1. Ответ API живёт только на сервере. В браузер он не попадает, если вы сами его туда не передали. Его размер влияет на сеть между сервером и API, память процесса Node.js и объём кэша.
  2. RSC payload скачивает пользователь. На первой загрузке он встроен в HTML, при навигации по ссылкам загружается отдельно, а ссылки в зоне видимости <Link> подгружает заранее. Лишние килобайты в props повторяются на каждом переходе.
  3. JS-бандл от данных не зависит вообще. Его размер определяет граница "use client": всё, что импортирует файл с этой директивой, попадает в клиентский граф модулей.

Смешивать эти каналы — главная ошибка в рассуждениях про «лёгкие компоненты». Выборка полей в API не уменьшает бандл, а вынос логики на сервер не уменьшает payload, если результат всё равно передаётся в props.

Корректно ли брать всю модель на сервере и отдавать клиенту только нужные поля?

Да. Это основной и правильный паттерн App Router. Разница видна на одном компоненте.

Так в payload уходит весь объект: текст статьи, SEO-поля, автор, связанные товары:

tsx
// 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>
  );
}

Так — только то, что нужно кнопке:

tsx
// app/blog/[slug]/page.tsx — фрагмент
<LikeButton articleId={article.id} initialLikes={article.likes} />
tsx
// 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), список стоит запрашивать так:

ts
// 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 описывает модель такой, какой её отдаёт бэкенд, со всеми полями:

ts
// 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 — модуль целиком, фрагмент со списком выше дописывается в него же:

ts
// 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:

ts
// 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. Клиентская обёртка с состоянием не обязана импортировать то, что показывает:

tsx
// 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, телефоны, внутренние поляОбъект передан целиком или через ...restgrep по имени поля
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 первой загрузки:

bash
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:

bash
npx next experimental-analyze

Он показывает клиентский граф модулей с цепочками импортов — видно, через какой файл с "use client" в бандл попала библиотека.

Результаты: сколько весят данные на этом блоге

Замер на реальном контенте блога: 146 статей, JSON тех наборов полей, которые можно было бы передать клиентскому компоненту списка.

Что передаётсяJSONgzip
Статьи целиком с текстом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, дешевле трёх запросов с разными наборами полей.