Предисловие
В начале данной статьи я хотел бы сделать небольшое лирическое отступление и рассказать о том, как я вообще пришёл к тому подходу, о котором буду рассказывать. А то, что это оказалось закономерным итогом нескольких лет я понял, готовя к публикации данную статью.
Определённым началом можно назвать лето 2020 года — помните, был тогда COVID-19 и мы всей планетой целый год дружно сидели по домам. А вот летом 20 был небольшой перерыв, в который мы работали в офисе. И вот, отойдя компанией за кофе, мне и мой коллега Василий и говорит:
— Слушай, а что у тебя сайт не на нексте?
— А что?
— Ну так красивей же было бы, с плавными переходами и без перезагрузок…
За точность разговора за давностью лет не ручаюсь, но смысл примерно был такой. Я тогда парировал, то это надо будет ещё как-то придумать, чтобы статьи редактировать, да ещё и ноду поднимать… Но, откровенно говоря, мысль меня тогда задела.
Потом у меня был ряд статей, опубликованных здесь, и выступления на митапах, и небольшая лекция онлайн. Всё их объединяла одна тема — WordPress и его REST API.
Рождение технологии
В прошлом году, когда Telegram мне выписал последнее китайское предупреждение, я понял, что конечно всякие платформы — это хорошо и удобно, но свой блог лучше.
Тогда же решил и обновить немного свой блог в плане визуала и технологий, т.к. предыдущая версия была сделана ещё в 2017 году и в целом уже морально устарела.
Захотелось чего-то нового. Тогда-то я и вспомнил тот разговор про использование Next’а и все плюшки, которые нам даёт использование JS-фреймворков. Но переежать полностью на новые рельсы не очень хотелось, так как и времени это бы заняло достаточно, да и административная часть в WordPress очень и очень нравится, хотелось бы внутреннее содержание сохранить.
Да, первое что приходит в голову в таком случае — просто написать пользовательскую часть на Next.js, вывести через API для него данные, а админскую часть WordPress’а куда-нибудь спрятать. Это достаточно распространённый подход, даже есть специальная библиотека для Next’а — Faust.js.
Однако, всё это в любом случае требует наличия как минимум двух отдельных сервисов — клиенского с Node/Next, и админского с php/WordPress. Возможно, для каких-то крупных проектов решение вполне себе годное, но для персонального блога…
В итоге получилось несколько условий, которым должен был соответствовать новый сайт:
- Работать под управлением WordPress
- Не использовать дополнительные отдельные сервисы, т.е. работать по принципу всё-в-одном
- Создавать пользовательский интерфейс с помощью js-фреймворков
- Поддерживать микроразметки для того, чтобы поисковые системы, социальные сети и мессенджеры могли корректно распознавать содержимое
Решение оказалось, лежало практически на поверхности.
Основной принцип WPSSR
Суть технологии типичная для всех SSR-технологий с той лишь разницей, что здесь данные для отображения готовятся на стороне php. И да, это пожалуй одна из самых проблемных частей данного подхода, т.к. для разработки нужен fullstack-разработчик на php/js.
Важная ремарка: очень много разработчиков на WordPress как раз представляют собой таких специалистов, поэтому проблема не столь глобальна
Данных подход стоит на 3 обязательном и 1 опциональном принципах:
- SSD (Server-Side Data) — данные, формируемые на стороне сервера, которые затем передаются в JS для последующей обработки
- Backend for Frontend (BFF) — паттерн, который нам указывает, что мы разрабатываем backend под конкретный клиент и все передаваемые данные должны быть подогнаны под него
- ShadowMarkup — разметка для поисковых систем, социальных сетей и прочих краулеров
- Managed object cache — необязательный аспект, который, однако, позволяет добиваться максимальной скорости работы
Теперь каждый из принципов давайте разберём отдельно.
Server-Side Data
Один из самых фундаментальных вопросов — как передать данные в JS с сервера? Обычно используется принцип гидратации, при котором на сервере генерируется статический html, который потом в браузере обрабатывается фреймворком и в случае успеха добавляет обработчики.
С PHP всё сложнее, т.к. он понятия не имеет, как правильно генерировать такой html-файл, да и в целом такой подход видится больше как тупиковый.
Конечно, есть всегда достаточно простой вариант — просто по передаваемому идентификатору (хоть по адресной строке, хоть по параметрам в разметке) запрашивать через REST API данные и отображать. Метод 100% рабочий, но с точки зрения UX вызывает вопросы — шаг с загрузкой данных хотелось бы пропустить. Мы же уже загрузили страницу, не так ли?
Если вы хотя бы раз задумывались над тем, как различные данные с сервера передаёт тот же Next, то вы обязательно увидели бы в теле страницы отдельный тег script с идентификатором __NEXT_DATA__.
Поэтому, чтобы не изобретать велосипед внутри велосипеда, можно поступить ровно так же, добавив при генерации html-файла данные в JSON-формате, которые при инициалиации js-приложение читает и на его основе создаёт нужное нам состояние. Только зададим этому контейнеру передачи данных идентификатор __wpssr.
Для примера приведу его здесь в очень сокращённом виде:
<script id="__wpssr">{ "page": "Post", "payload": { "slug": "wpssr", "format": "standard", "title": "WPSSR", "date": { "published": "2025-11-10 20:42:50", "modified": "2025-11-10 20:42:50", } }, "global": {}</script>Наш передаваемый объект состоит из 2 обязательных признаков:
page— это шаблон страницы, который необходимо использовать для вывода данных; в зависимости от сложности проекта их может быть сколько угодно, в моём случае имеются как минимумHome,PostиSearch. Отметьте, что названия типа страницы пишется с большой буквы — в идеале, оно должно совпадать с названием компонента, который отвечает за её отрисовкуpayload— это передаваемые данные для страницы, которые ожидает указанный в первом параметре шаблон.global— параметр, в котором для страницы передаются глобальные данные для сайта; например, в моём блоге на всех страницах есть блок с автором (где моя аватарка и ссылки на другие ресурсы) — вот они передаются там
Для передачи данных из json в приложение используется ряд специальных функций, у которых есть одна корневая:
export const getWPSSR = (): TWPSSR<any> | null => { const element = document.getElementById('__wpssr'); if (element?.textContent) { try { return JSON.parse(element.textContent); } catch (e) { return null; } } return null;};Принцип её работы достаточно прост — при инициализации компонент, отвечающий за отрисовку страницы, запрашивает данные из ssd-контейнера и если в нём содержится та страница, которая открыта в данный момент, то забираем данные из неё. Иначе запрашиваем через REST.
Теперь давайте разберём более подробно процесс запроса данных на примере главной страницы.
У нас есть компонент HomePage, который при инициализации получает изначальный стейт, который заполняется чере специальную функцию getHomeData:
export const getHomeData = (): TPostsList => { const currentData = getWPSSRHomePage(); if (currentData && currentData.page === 'Home') { return currentData.payload; } return initialPostsList;};Внутри себя она вызывает ещё одну функцию getWPSSRHomePage, которая является по сути расширением ранее упомянутой getWPSSR:
export const getWPSSRHomePage = (): TWPSSR<TPostsList> => { const data = getWPSSR(); if (data !== null && data.page === 'Home') { return data; } return { page: 'Home', payload: initialPostsList, meta: null };};Здесь я несколько раз перестраховываюсь и если что не так, то просто возвращаю initialPostsList, который представляет собой объект с пустым списком — намёк приложению, чтобы оно сделало запрос в API.
Backend for Frontend
Если мы взглянем на TPostsList, то нас там встретит вот такой объект:
export type TPostsList = PaginationDTO<PostCardDTO[]>;Сейчас не будем вдаваться в подробности всех типов и интерфейсов, отметим только, что у нас появились названия с постфиксом DTO. Он здесь неспроста — это явное указание на то, что объект данного типа используется и на frontend’е, и на backend’е.
Для примера не будем уходить далеко — вот у нас есть PaginationDTO:
export interface PaginationDTO<T> { total: number; page: number; lastPage: number; items: T;}И вот он же, но уже в PHP:
class PaginationDTO{ public function __construct( public int $total, public int $page, public int $lastPage, public array $items = [], ){} public function getItems(): array { return $this->items; }}Но не только универсальностью типов данных едины. Поскольку для frontend-приложения стандартный объект WP_Post подходит слабо, то вместо него приходит модернизированная версия:
export interface PostFullDTO { slug: string; format: PostFormat; title: string; description: string; content: PostContentDTO[]; category: PostCategoryDTO; keywords: string[]; thumbnail: ThumbnailDTO; author: AuthorDTO; date: PostDateDTO; lastPosts: PostCardDTO[]; similar: PostCardDTO[];}Да, здесь нет никаких стандартных ID и прочих атрибутов, только то, что нужно для js единым объектом.
Кстати, в данном объекте есть ещё один интересный параметр. Если у нас description — это по сути wp_posts.post_excerpt, то вот content уже не просто wp_posts.post_content. Это было не обязательно, но тут я заморочился и сделал так, чтобы блоки Gutenberg-редактора трансформировались в отдельные объекты — таким образом мы не занимаемся вставкой html, а можемт очень гибко управлять каждым элементом записи.
ShadowMarkup
Итак, мы передали на клиент данные нужного ему формата, в этом плане всё отлично. Но, если мы говорим про разработку сайтов, которые должны парсится в интернете, то нам необходимо создать микроразметку для сайта, чтобы различные роботы и краулеры смогли его проиндексировать.
Конечно, тут возникает дилемма — мы же вроде бы сделали уже один раз вёрстку на js, не переделывать же это вторично? Естественно, что не нужно. Но какая-то минимальная разметка нам в любом случае нужна.
И здесь нам на помощь сразу же приходит сам WordPress — при создании записей в редакторе они уже имеют нормальную разметку. Останется всего лишь добавить минимум.
Вот так будет выглядеть главная страница:
$markup = '<header itemscope itemtype="https://schema.org/WebSite">' . '<h1 itemprop="name" >'. $title .'</h1>' . '<p itemprop="description" >'. $description .'</p>' . '</header>' . postsList('Последние публикации', $posts->items);Причём заметьте, что на главной странице используется SSR-компонент для списка постов, поэтому он выводится отдельной функцией (чтобы не повторяться):
/** * @param string $title * @param PostCardDTO[]|array $posts * @return string */function postsList( string $title, array $posts): string{ return '<main aria-labelledby="recent-posts" aria-label="'. $title .'">' . '<h2 id="recent-posts">'. $title .'</h2>' . '<ol>' . implode('', array_map(function (PostCardDTO $cardDTO) { return '<li itemscope itemtype="https://schema.org/BlogPosting" itemprop="blogPost">' . postCard($cardDTO) . '</li>'; }, $posts)) . '</ol>' . '</main>';}И в итоге получившуюся строку $markup мы передаём в специальную функцию, которая генерирует итоговый html со всеми микроразметками, а также ssd-контейнером.
Таким образом, когда на наш сайт попадёт любой краулер, то он не заблудится.

Также распознаются и ссылки на внешние ресурсы:

Managed object cache
Выше мы несколько раз рассматривали нестандартные объекты данных, которые могут быть как достаточно простыми, так и сложносоставными т.е. для одного объекта нужно выполнить несколько дополнительных запросов.
И если для сайтов с небольшим количеством посетителей это некритично, то вот при нагрузке это уже может иметь проблемы.
И здесь вступает последняя, необязательная ступень подхода — Managed object cache.
Принцип на самом деле максимально простой — мы создаём кэшированные объекты данных. Все методы получений данных оформлены в отдельные action-классы, которые имеют специальную проверку на наличие кэшированного результата:
public function execute(WP_Post $post): PostFullDTO{ $cached = (new WPSSRCache())->get('post', $post); if($cached) { return $cached; } // Тут дальнейшая логика}При этом мы можем использовать как подключение к Redis (если таковой есть), так и просто сохранять в виде json-файлов. Независимо от этого мы получаем достаточно высокую скорость, при этом убираем ненужные запросы в базу, если у нас уже есть готовые данные.
При этом мы можем сохранять не только отдельные записи, но и даже списки записей для главной страницы, для страниц категорий и прочих таксономий.
Отдельно хочу заметить, что здесь мы генирируем именно что объекты данных, а не результирующий html — это необходимо для того, чтобы при запросе данных через REST мы получили тот же кэшированный объект.
Плюсы и минусы данного подхода
Плюсы и минусы данного подхода
Как и всего, у данного подхода есть преимущества и недостатки. Начнём, пожалуй, с последних и накинем несколько, которые видны сразу:
- Требования к разработчику: как и упоминал ранее, для того, чтобы пользоваться данной концепцией, необходимо быть fullstack-разработчиком.
- Отсутствие готового фреймворка: WPSSR — это не библиотека, а концепт, для реализации которого придётся реализовывать инфраструктуру руками: сборку JS, внедрение контейнера, маршрутизацию и т.д.
- Нестандартная реализация: плавно вытекающий из предыдущего пункта параметр, поскольку нет ни готовых паттернов, ни законченной формализованной документации, и работа с таким кодом в принципе может вызывать сложность.
- SEO и ShadowMarkup: требуют ручной работы, а следовательно, либо знаний у разработчика, либо наличие SEO-специалиста.
Теперь выделим и плюсы, которые есть:
- All-in-One: пожалуй, одно из главных преимуществ, т.к. вся система работает в рамках одного приложения, что существенно упрощает его развёртывание и управление, что особенно актуально для небольших проектов
- Не ограничены никаким js-фреймворком: кроме того, что мы используем WordPress, более ограничений с точки зрения использования js-фреймворков мы не имеем. Можно использовать вообще любой.
- Возможности WordPress: у нас уже имеется готовая, достаточно сильно проработанная система управления контентом, а кроме этого — буквально тысячи плагинов, расширяющих функционал.
- SEO- и share-friendly: использование микроразметки и статического HTML позволяет поисковым системам и соцсетям корректно парсить страницы.
Есть ещё несколько, возможно, спорных моментов, которые я всё же отнёс бы плюсам данного подхода:
- Скорость разработки интерфейсов: за многие года WordPress оброс просто тонной решений относительно того, как можно собирать пользовательский интерфейс. В конце концов можно вспомнить и текущий FSE-подход, когда тема — это просто набор блоков, который мы можем прямо в редакторе собирать. Но если мы говорим про кастомные решения, то использовать готовые UI-библиотеки всё равно гораздо проще, да и зачастую быстрее.
- Раздельная разработка: мы можем разделить процесс между frontend и backend разработчиками, что может существенно ускорить процесс.
- Авторизация: она уже есть на стороне WordPress, достаточно надёжная и в целом очень очевидная. Если нужно для авторизованных пользователей выдавать какой-то дополнительный контент — достаточно проверить его на серверной стороне, а далее уже заполнять ssd-контейнер нужными данными. В целом, даже сам механизм авторизации на js можно не делать, т.к. условный объект пользователя придёт сразу при инициализации, а если авторизация закончится, то первый rest-запрос вернёт 401 ошибку.
Здесь я привёл только те плюсы и минусы, которые вижу лично, а на деле их может быть гораздо больше.
Заключение
Наконец-то смог закрыть свой гештальт и рассказать о своём подходе 😎
Хотя идея и появилась достаточно давно, но вот опубличить результаты своих исследований смог только сейчас.
WPSSR — это гибридный подход между классическим WordPress и headless-архитектурой, ориентированный на небольшие и средние проекты.
Он выигрывает в простоте инфраструктуры, удобстве управления и SEO, но требует более глубоких технических навыков и ручной настройки.
На данном подходе уже написаны несколько сайтов, в том числе — этот блог, который вы сейчас читаете.
Самое главное, что у меня получилось сделать то, что хотелось — свобода фронтенда, мощь WordPress. И на самом деле мне настолько оно доставило, что пожалуй, большую часть сайтов, которые буду делать в дальнейшем, буду делать именно так.
