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

URL: https://dmeshcheryakov.ru/blog/nextjs-server-data-client-props/
Раздел: NextJS
Теги: NextJS, React, Performance, API
Опубликовано: 2026-10-01
Обновлено: 2026-10-01
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

На статье этого блога 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" и всё, что он импортирует
```

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

```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 тех наборов полей, которые можно было бы передать клиентскому компоненту списка.

| Что передаётся | 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 как фронтенд для Битрикса](/blog/nextjs-headless-bitrix). Как кэшировать ответы на сервере, разобрано в материале про [кэширование в Next.js](/blog/nextjs-ssr-caching), а если данные всё-таки нужно запрашивать из браузера — в статье про [React Query в Next.js](/blog/react-query-nextjs).

## Итоги

- Брать модель целиком в серверном компоненте и передавать клиентским компонентам только нужные поля — правильно. Сырой ответ API в браузер не попадает.
- Клиентский компонент получает props целиком. Защищают примитивы и явно собранные объекты, а не тип `Pick`.
- Маппер DTO — белый список полей. Деструктуризация с `...rest` — чёрный список, и он протекает при первом же новом поле в API.
- Выборку полей в API стоит делать для списков, кэшируемых данных и тяжёлых связей. Для детальной страницы с полной моделью она не нужна.
- Бандл зависит от того, что импортировано за `"use client"`, а не от данных. Форматируйте на сервере, держите директиву в листьях дерева.

И последнее, менее очевидное: перенос рендера на сервер не уменьшает размер HTML. Серверный компонент экономит JavaScript, но его результат всё равно лежит в странице дважды. Если страница тяжёлая из-за объёма контента, а не из-за кода, помогает пагинация, а не замена `"use client"` на серверный компонент.