Рано или поздно каждая headless CMS выпускает набор компонентов для React, и большинство из них устроены одинаково: оберните приложение в провайдер, вставьте <BlogList />, и компонент сам загрузит посты. Демо получается приятным. И это было разумное решение, когда каждое React-приложение было одностраничным.
Сейчас это неверное решение. Когда есть React Server Components, лоадеры маршрутов в TanStack Start и React Router, а серверный рендеринг включён по умолчанию в каждом серьёзном фреймворке, компонент, который сам загружает данные, воюет с фреймворком, в котором работает. Я только что выпустил для Kookee компоненты блога, журнала изменений, центра помощи и дорожной карты и построил их на обратном правиле. Ниже — почему, и шесть правил, которые из этого вышли.
Что ломается, когда компонент сам загружает данные
Возьмём классическую схему:
<CmsProvider apiKey="…">
<BlogList limit={10} />
</CmsProvider>
BlogList вызывает хук, хук загружает данные в эффекте, и список рендерится, когда данные пришли. Отсюда следуют четыре вещи, и ни одна не радует.
Контента нет в HTML. Эффекты не выполняются на сервере. В первом HTML, который получает посетитель или поисковый робот, на месте постов спиннер. Наборы обходят это собственным шагом предзагрузки, а это та же загрузка данных из фреймворка, только изобретённая заново внутри компонента.
Всё становится клиентским компонентом. Компонент, который вызывает хуки, должен работать в браузере, поэтому весь список, его карточки и разметка уходят в браузер как JavaScript — ради контента, который после рендера не меняется.
Запросы выполняются не там. Ваш фреймворк уже умеет загружать данные: в серверном компоненте, в лоадере, со своим кешем и своей ревалидацией. Компонент, который загружает данные при монтировании, обходит всё это, а вложенные компоненты, каждый со своей загрузкой при монтировании, грузятся друг за другом, а не параллельно.
Контент идёт по сети дважды. В Next.js App Router каждый проп клиентского компонента сериализуется в страницу, чтобы React мог гидратировать компонент. Передайте клиентскому компоненту целый пост, и текст статьи придёт один раз как HTML и ещё раз внутри данных страницы.
Правило 1: принимать данные через пропсы
Компонент должен быть функцией от данных. Ему незачем знать, откуда данные пришли.
// app/blog/page.tsx, серверный компонент
const { data } = await kookee.blog.list({ limit: 12 });
return <BlogPostList posts={data} href="/blog/{slug}" />;
Загрузка происходит там, где её ждёт ваш фреймворк: в серверном компоненте, в лоадере или, в одностраничном приложении, в хуке, чей data вы передаёте напрямую. Один и тот же компонент работает во всех трёх случаях. Ему не нужен провайдер, у него нет своего состояния загрузки, а кеширование — то, которое уже есть в вашем фреймворке.
Заодно такой компонент легко тестировать. Дайте ему массив и посмотрите на разметку.
Правило 2: по умолчанию сервер, клиент — только там, где нужен браузер
Большая часть контентной страницы статична: списки, карточки, статьи, хлебные крошки, шапки. Браузер нужен лишь малой части. В блоге это кнопки реакций и оглавление, которое подсвечивает читаемый раздел. В центре помощи — строка поиска и голосование «Была ли эта статья полезной?».
Поэтому у каждого раздела с интерактивными частями два пути импорта:
@kookee/react/helpрендерится на сервере, без состояния и без браузерных API;@kookee/react/help/client, как и другие пути/client, содержит интерактивные части с пометкой'use client'.
С серверными компонентами страница рендерит почти всё на сервере и гидратирует только те немногие острова, которым это нужно.
Это разделение не просто описано в документации: его проверяет сборка. Проверка запускается на публикуемых файлах и падает, если серверная точка входа создаёт React-контекст, помечена 'use client' или импортирует хук, который React не экспортирует для серверных компонентов. Там React экспортирует всего несколько хуков — useMemo, useCallback, useId и useDebugValue, — и только эти четыре проверка пропускает. Та же проверка падает на любой точке входа компонентов, которая импортирует что-то кроме React и SDK или весит больше 10 КБ в gzip.
Правило 3: клиентские компоненты принимают примитивы
Интерактивные части — клиентские компоненты, поэтому их пропсы сериализуются в страницу. Они принимают минимум, которого хватает для работы.
Кнопки реакций не принимают пост. Они принимают id поста и счётчики:
<Reactions entryId={post.id} reactions={post.reactions} config={kookeeConfig} />
Оглавление не принимает текст статьи, чтобы разобрать его в браузере. Заголовки извлекает сервер, и клиентский компонент получает только их:
<TrackedTableOfContents headings={extractHeadings(post.contentHtml)} offset={64} resetKey={post.id} />
Текст длинной статьи остаётся в HTML, где ему и место, а для гидратации передаются несколько id, счётчиков и заголовков.
Правило 4: фильтры и пагинация — это ссылки
У фильтра по тегам на кнопках, которые меняют состояние, три проблемы: он не может рендериться в серверном компоненте, ничего не делает, пока не загрузится JavaScript, а у отфильтрованного вида нет адреса, которым можно поделиться или который может обойти поисковик.
У фильтра по тегам на ссылках нет ни одной из них:
<BlogTagFilter tags={tags} activeTagSlug={tag} href="/blog?tag={tagSlug}" allHref="/blog" showCounts />
<Pagination page={page} totalPages={totalPages} href={(n) => (n === 1 ? '/blog' : `/blog?page=${n}`)} />
Страница рендерится заново для нового URL, а серверный компонент делает это в любом случае. Поиск по блогу следует тому же правилу: форма с GET — это пять строк HTML, поэтому отдельный компонент для неё я не выпускал. Пагинация оказалась важна не только ради удобства, и почему — я разобрал в отдельном посте о пагинации блога, по которой могут пройти поисковики.
Правило 5: поведение — в хуках, разметка — по желанию
Каждый интерактивный компонент — тонкий слой разметки над headless-хуком: useReactions, useActiveHeading, useWhatsNew, useHelpSearchBox. Хук отвечает за состояние, запросы, оптимистичные обновления и пограничные случаи. Компонент — лишь один из способов всё это отрисовать.
Если моя разметка не подходит к вашему дизайну, оставьте поведение и напишите свою:
const { counts, mine, toggle, isPending } = useReactions({ entryId, reactions, config });
То же с логикой в серверных компонентах. Извлечение заголовков, окно пагинации и группировка дорожной карты — обычные функции (extractHeadings, getPaginationItems, groupRoadmapPosts), которые можно вызвать из своей разметки.
Правило 6: контракт стилей — это API
Набор компонентов полезен ровно настолько, насколько его можно настроить, а настраивать безопасно, только когда вы знаете, что не поменяется у вас под ногами. Поэтому я отнёсся ко всему, что касается стилей, как к публичному API с теми же гарантиями, что и у пропсов:
CSS-переменные задают тему:
--kookee-primary,--kookee-radius, по цвету на каждый тип записи журнала изменений.Стабильные имена классов и слоты. У каждого элемента есть класс, и проп
classNamesдотягивается до каждого:classNames={{ title: 'text-xl' }}.Атрибуты
data-*показывают состояние:data-active,data-pressed,data-vote,data-type. Нажатую реакцию можно оформить без JavaScript.Части и функции рендера. Каждый стандартный макет собран из экспортируемых частей, а
renderItem,renderLinkиrenderImageподставляют вместо них ваши.
Имена классов и слотов, атрибуты data-*, ключи подписей и переменные меняются только в мажорном релизе. На вложенность элементов внутри компонента это обещание не распространяется: заморозить её значило бы заморозить сами компоненты. Если ваш CSS зависит от структуры, собирайте компонент из частей.
Из серверного рендеринга вытекают ещё два небольших решения. Даты абсолютные и считаются в UTC, поэтому сервер и браузер выводят одну и ту же строку и при гидратации не бывает расхождений. А таблица стилей необязательна: без неё все классы и атрибуты на месте, так что команда со своей дизайн-системой может написать весь CSS сама.
Сначала опробуйте на себе, потом публикуйте
Ещё одно правило — про процесс, а не про код: перед публикацией я перевёл на эти компоненты готовый портал Kookee и сам kookee.dev, раздел за разделом. Каждая страница должна была совпасть с прежним дизайном, а если не получалось, это был пробел в компоненте, а не в странице.
Это сразу окупилось. При переезде нашлось оглавление, которое застревало на последнем заголовке; причина оказалась в том, что React 19 перезаписывает dangerouslySetInnerHTML при каждом повторном рендере родителя. Нашёлся парсер заголовков, который падал на записи без текста. Ни то ни другое не проявилось в песочнице компонентов, и обе ошибки ушли бы к клиентам.
Коротко
Принимайте данные через пропсы. Загрузку оставьте фреймворку.
По умолчанию рендерите на сервере, интерактивные части выносите в путь
/client.Передавайте клиентским компонентам id и счётчики, а не записи целиком.
Делайте фильтры и пагинацию ссылками.
Поведение держите в headless-хуках, логику — в обычных функциях.
Относитесь к именам классов, слотам, атрибутам
data-*и CSS-переменным как к API.
По этим правилам собраны новые компоненты в @kookee/react: блог, журнал изменений со значком «что нового», центр помощи с поиском и голосованием за полезность и дорожная карта только для чтения. Начните со статьи React-компоненты для блога, журнала изменений и центра помощи или посмотрите, как оформить и настроить их.