Senior Frontend Architect, 10+ лет опыта построения production-проектов на Next.js. Contentful Certified Professional (2024). Специализация: React Server Components, headless eCommerce, инженерия Core Web Vitals.
Недавно я перенёс мультиязычный маркетинговый сайт с WordPress на Payload CMS, и эта работа научила меня относиться к миграции как к data-пайплайну, а не как к экспорту в один клик. Плагины уровня "экспорт в JSON в один клик" начинают трещать сразу, как только у вас есть переводы WPML, метаданные Yoast SEO, перемешанная вёрстка классического редактора и Gutenberg, и десять лет PDF-файлов в wp-content/uploads, на которые до сих пор ссылаются внешние сайты. Дальше - продакшн-подход, который я применил: многоэтапная скриптовая миграция на Payload 3.77 с PostgreSQL на Neon и деплоем на Vercel. Разберём решения по стеку, конвертацию HTML в Lexical, которая лежит в сердце любой миграции WordPress, сохранение медиа и SEO, стратегию 301-редиректов и то, как изменилась повседневная работа разработчика после переезда.
Почему Payload, а не очередной плагин WordPress
WordPress привязывает контент к PHP-теме, набору плагинов и таблице wp_postmeta - паре ключ-значение, которая с годами превращается в болото. Цель переезда была в том, чтобы взять модель контента под контроль версий и отдавать её со стека, который я полностью контролирую ради производительности и SEO. Payload даёт именно это: коллекции и поля описываются на TypeScript, схема - это код, база - PostgreSQL с миграциями через Drizzle, rich text - Lexical, а в Payload 3 всё это работает внутри Next.js-приложения. Вместо борьбы с темой фронтенд - это React Server Components, которыми я владею от начала до конца. Именно это сочетание - модель контента в виде кода, работающая как Next.js-CMS, - и делает Payload настоящей альтернативой WordPress для контентного сайта, а не просто очередным headless-API.
Стек: Payload 3, PostgreSQL на Neon, Vercel
Целевой стек был намеренно скучным и современным. Все версии ниже - это то, что реально уехало в прод, а не абстрактная рекомендация.
- Payload 3.77 на Next.js 15.4 / React 19: Payload 3 работает *внутри* Next.js-приложения - админка и REST/GraphQL API являются роутами Next.js, а не отдельным сервером. Одна кодовая база, один деплой, один рантайм.
- PostgreSQL через `@payloadcms/db-postgres` 3.77: реляционный адаптер на базе
node-postgres. Контент реляционный и локализованный, поэтому настоящая SQL-база с внешними ключами была правильнее документного хранилища. - Neon как управляемый Postgres: подключение через
POSTGRES_URL_NON_POOLINGдля миграционных скриптов (чтобы обойти ограничения PgBouncer на prepared statements), сconnectionTimeoutMillisв 30 000 под холодные старты и окна обслуживания Neon иidleTimeoutMillisв 60 000 для переиспользования простаивающих соединений. - Vercel Blob для медиа через `@payloadcms/storage-vercel-blob` 3.77: каждая загрузка в коллекцию
mediaхранится в Blob и отдаётся с edge. - Lexical rich text через `@payloadcms/richtext-lexical` 3.77: поле контента, заменяющее
post_contentиз WordPress. Это и есть главная причина, почему миграция нетривиальна - ей посвящён весь Этап 2 ниже.
Решение по Postgres-адаптеру, обеспечившее стабильность миграции
У Payload есть serverless-адаптер для Neon/Vercel, и я сознательно его не использовал. На глубоко вложенных запросах "страница с блоками" - когда страница собрана из десятка типов блоков, у каждого свои связи - serverless-адаптер оказался ненадёжным, тогда как обычный node-postgres через @payloadcms/db-postgres с пулом соединений и явными таймаутами оставался безупречно стабильным именно на таких запросах. Ещё одна практическая деталь: schema push (push: true) включён только на localhost для быстрой итерации; Neon никогда не получает schema push - только закоммиченные и отревьюенные миграции Drizzle. Это разделение делает прод-схему детерминированной.
Почему PostgreSQL и Drizzle, а не MongoDB
Payload поддерживает и MongoDB, и PostgreSQL. Я выбрал Postgres, потому что контент по своей природе реляционный и локализованный: посты ссылаются на категории, теги и медиа по внешним ключам, а каждое переведённое поле живёт в таблице на локаль вроде posts_locales. Изменения схемы уезжают как датированные TypeScript-миграции - значит, история базы видна в pull request и обратима. Уйти от wp_postmeta WordPress - единой EAV-таблицы, где любая настройка лежит сериализованной строкой - было самостоятельной целью, а не побочным эффектом.
Миграция - это пайплайн: Extract, Transform, Localize
Миграция работает в три яруса. Extraction тянет контент из живой базы WordPress, с продакшн-HTML как запасным вариантом, когда база недоступна. Transformation конвертирует этот HTML в Lexical, загружает медиа и создаёт документы Payload. Localization разносит контент по локалям и заново привязывает SEO-метаданные. Ничего из этого не уезжает в приложение: скрипты лежат в отдельных каталогах для миграций и сидов вне бандла приложения, каждый идемпотентен, и любая запись закрыта за dry-run. Когда всю эту махину можно прогнать десять раз подряд, ничего не сломав, переключение перестаёт ощущаться рискованным событием и становится рутиной.
Этап 1 - Extraction: читаем WordPress на уровне базы
WordPress хранил всё в MariaDB. Вместо REST API - который тихо теряет состояние черновиков и нюансы метаданных - я запрашивал wp_posts и wp_postmeta напрямую. Переводы пришли из wp_icl_translations WPML: колонка trid группирует пост со всеми его переводами, а language_code даёт локаль. Одна деталь оказалась критичной: я кодировал post_content в base64 в выводе SQL, чтобы переносы строк и сырой HTML пережили парсинг TSV, а затем писал по одному JSON-файлу на локаль.
- Идентичность и структура:
post_id, WPMLtridи slug, чтобы пересобрать переводы и сохранить URL. - Контент и даты: base64
post_content,post_dateиpost_modifiedдля точных меток публикации и обновления. - Ссылки на медиа:
_thumbnail_idдля главного изображения плюс regex-проход по инлайновым URLwp-content/uploads/...в теле. - SEO и таксономия: полный блок метаданных Yoast плюс slug категорий и тегов для маппинга связей.
Этап 2 - Самое сложное: конвертация HTML в Lexical
Payload 3 хранит rich text как Lexical JSON, а не HTML. Десять лет WordPress - это смесь разметки классического редактора и блочных комментариев Gutenberg. Я написал детерминированный HTML-в-Lexical конвертер, а не доверился универсальной библиотеке, потому что мне нужен был контроль над каждым крайним случаем за десять лет легаси-контента.
- Блочные узлы:
<p>становитсяparagraph;<h1>-<h3>становятся узламиheading, прижатыми к диапазонуh2-h4, который разрешает редактор;<li>становитсяlistitemвнутри узлаlist. - Инлайн-форматирование как битовая маска: Lexical кодирует форматирование текста целочисленной маской -
<strong>/<b>ставят формат1,<em>/<i>-2, а вложенные жирный-плюс-курсив дают3. - Ссылки:
<a>становится узломlinkсо своимhref, аtarget="_blank"маппится вnewTab: true. - Сущности и пробелы: декодируем сущности, которыми WordPress засоряет всё подряд -
’в прямой апостроф, пару типографских кавычек в прямые,&в амперсанд - и полностью выбрасываем абзацы из одних пробелов и .
Пропуски так же намеренны, как и переносы: произвольные инлайн-стили и любая блочная структура за пределами заголовков и списков выкидываются, так что миграция заодно работает чисткой контента. Всё действительно более богатое - callout-блоки, галереи, эмбеды - маппится в отдельные блоки Payload на этапах уровня страниц, а не протаскивается как сырой HTML.
Этап 3 - Медиа и проблема wp-content/uploads
Задач с медиа было две. Главные изображения - простая: читаем каждый файл как буфер, определяем MIME-тип по расширению и делаем payload.create() в коллекцию media, которая стоит на Vercel Blob; возвращённый ID документа становится heroImage поста, а дефолтная обложка - запасным вариантом, когда изображения не было. Сложная - инлайновые медиа, в частности 100+ PDF в wp-content/uploads/YYYY/MM/..., на которые годами ссылались внешние сайты и поисковик. Для них я скачивал каждый файл с продакшена, заново заливал в Vercel Blob под префиксом wp-uploads/ и прогонял SQL REPLACE по JSONB-колонкам контента, переписывая все старые URL на месте.
Одна настройка Payload стоит упоминания: я выставил disablePayloadAccessControl: true для Blob-хранилища, чтобы база хранила полный публичный URL Blob напрямую, а не гоняла каждый файл через /api/media/file/.... Меньше прыжков на изображение, и URL кешируется на edge без обращения к приложению.
Этап 4 - Не потеряйте SEO: миграция метаданных Yoast
Тихий способ убить трафик миграцией - выбросить SEO-метаданные. Yoast хранит всё это в wp_postmeta, поэтому я написал отдельный импортер, который читал каждый ключ _yoast_wpseo_* и маппил его в группу meta Payload, а не давал ему умереть при конвертации HTML.
- Заголовки и описания:
_yoast_wpseo_titleи_yoast_wpseo_metadescмаппятся вmeta.titleиmeta.description. - Канониклы:
_yoast_wpseo_canonicalмаппится вmeta.canonicalс попутным переписыванием старого local/staging-origin на продакшн-origin. - Директивы индексации:
_yoast_wpseo_meta-robots-noindexи-nofollowмаппятся вmeta.noindexиmeta.nofollow. - Сигналы таргетинга:
_yoast_wpseo_focuskw,keywordsynonyms,is_cornerstoneиschema_page_typeмаппятся в отдельные поля, чтобы редакторский замысел сохранился.
Импортер матчил записи по (collection, slug, locale), по умолчанию работал в режиме dry-run и писал в Postgres только за явным флагом --apply. Поля Open Graph и Twitter-карточек переехали тем же путём, так что превью в соцсетях пережили переключение без изменений. Перенос SEO отдельным аудируемым проходом - а не в надежде, что оно само уедет внутри контента - это то, что сохраняет уже заработанные позиции вместо того, чтобы собирать их заново с нуля.
Этап 5 - 301-редиректы: сохраняем ссылочный вес
Пермалинки WordPress редко совпадают с роутингом нового фреймворка, и каждому проиндексированному URL или внешне-залинкованному PDF нужен 301, иначе вы возвращаете позиции поисковику. Я собрал манифест редиректов - включая 100+ старых путей загрузок - и настроил постоянные редиректы так, чтобы старые URL wp-content/uploads/... вели на их новые места /media/... на Blob. Это самый незаметный этап - и тот, который я считаю обязательным: именно он стоит между тихим переключением и просадкой трафика, которая всплывает на второй неделе.
Этап 6 - Локализация на 30+ рынков
Сайт охватывал около 30 локалей. Группы переводов trid из WPML чисто легли на локализацию Payload, которая держит переведённые поля в таблицах на локаль. Я сначала засидил мастер-локаль, затем прогонял скрипты применения локалей, которые копировали структуру в каждую новую локаль через SQL и накладывали сверху локаль-специфичные оверрайды. После каждого изменения схемы payload generate:types перегенерировал TypeScript-типы, так что админка и кодовая база не расходились - гарантия, которую WordPress попросту не даёт.
Идемпотентность, dry-run и почему код миграции не в приложении
Скрипты миграции никогда не уезжали в бандл приложения - они лежат в отдельных каталогах для миграций и сидов вне рантайма. Три свойства сделали их безопасными для прогона по живой базе. Они идемпотентны: повторный запуск не создаёт дублей, потому что любая запись сначала матчится по slug и локали. Они dry-run по умолчанию: каждый разрушающий шаг требует явного --apply. И они аудируемы: каждый шаг логирует, что он сматчил и что изменил бы, так что dry-run - это читаемый diff до того, как что-то коснётся Postgres.
Как выглядит работа разработчика после переезда
Выигрыш - в том, что модель контента стала кодом. Коллекции вроде Posts и Pages - это TypeScript-файлы, так что добавить поле - это коммит и миграция, а не установка плагина. payload generate:types даёт сквозную типобезопасность от базы через API Payload в React-компоненты. Локально Postgres крутится в Docker со включённым schema push для быстрой итерации, а Neon получает только закоммиченные миграции. Редакторы получают чистый редактор Lexical и блочный конструктор страниц вместо стены из шорткодов. Нет беговой дорожки обновления плагинов, не нужно гнаться за версиями PHP и нет functions.php, тихо накапливающего бизнес-логику.
Что реально стало лучше
- Сквозная типобезопасность: одни и те же типы контента идут из Postgres через API Payload в Server Components Next.js, поэтому переименованное поле - это ошибка компиляции, а не сюрприз в проде.
- Нет разрастания плагинов: поведение, которое было пятью плагинами WordPress, теперь - пара сотен строк отревьюенного TypeScript.
- Реляционная целостность: категории, теги, медиа и локали - это внешние ключи в Postgres, а не сериализованные строки в
wp_postmeta. - Рендеринг, который я контролирую: так как фронтенд - это Next.js App Router на Vercel, Core Web Vitals и SEO стали инженерными решениями, а не ограничениями темы.
- Один рантайм: Payload 3 живёт внутри Next.js-приложения, поэтому CMS, API и публичный сайт собираются и деплоятся вместе.
SEO после переезда: лучше, чем на WordPress, и без счёта за плагины
Деталь, которая волновала клиента больше всего: поисковые показатели пережили переключение и затем выросли - без единого платного SEO-плагина. На WordPress SEO-стек был регулярным счётом: Yoast Premium для метаданных и редиректов, плагин кеширования для Core Web Vitals, оптимизатор изображений - у каждого свой цикл обновлений и свой тормоз для админки. На Payload те же возможности - часть приложения. Группа meta даёт каждому документу title, description, canonical и robots-директивы как типизированные поля; редиректы живут в отслеживаемом манифесте; а Core Web Vitals - это результат рендеринга Next.js, который я контролирую, а не плагин в аренду. Более чистый серверный HTML, быстрые страницы и метаданные, проверенные на уровне типов, дают краулеру меньше поводов сомневаться - и позиции это отразили. Очевидный следующий шаг - генерация полей meta.title и meta.description автоматически, AI-проходом по контенту - тема для отдельной статьи.
WordPress - хорошее место для старта и тяжёлое место для масштабирования кастомного, мультиязычного, критичного к производительности сайта. Переезд на Payload не был экспортом в один клик; это был пайплайн, который я мог прогнать, проаудитить и перезапустить, пока данные не вышли чистыми. Своё отработали самые незаметные части - конвертер HTML в Lexical, импортер метаданных Yoast и манифест редиректов. Сделайте эти три правильно - и переезда никто не заметит: ни ваши читатели, ни поисковики.
Достаточно ли плагина экспорта WordPress для настоящей миграции?
Нет, для чего-либо сложнее хобби-блога. Плагины экспорта теряют связи переводов WPML, метаданные Yoast и различие между классическим и Gutenberg-контентом, и ничего не делают с инлайновыми URL wp-content/uploads, от которых зависят внешние сайты. Скриптовый многоэтапный пайплайн - вот что выживает при встрече с десятилетним инстансом.
Почему PostgreSQL вместо MongoDB для Payload?
Потому что контент реляционный и локализованный. Внешние ключи к категориям, тегам и медиа плюс таблицы на локаль и отслеживаемые миграции Drizzle дают целостность и обратимую историю схемы, которой нет у документного хранилища. Postgres на Neon к тому же естественно сочетается с деплоем на Vercel.
Как HTML из WordPress хранится после переезда?
Как Lexical JSON, а не HTML. Детерминированный HTML-в-Lexical конвертер маппит абзацы, заголовки, списки, инлайн-форматирование и ссылки в узлы Lexical, декодирует HTML-сущности и выбрасывает пустые абзацы - тогда как инлайн-стили и экзотическая блочная структура намеренно отбрасываются в рамках чистки.
Будут ли работать старые URL и залинкованные PDF?
Да, если относиться к редиректам как к полноценному этапу. Каждый старый URL - включая 100+ PDF из wp-content/uploads - получает постоянный 301 на новое место на Blob, так что внешние ссылки и позиции в поиске переносятся, а не ломаются.
Источники
- Документация Payload CMS - коллекции, поля, локализация и access control.
- PostgreSQL-адаптер Payload - настройка
@payloadcms/db-postgresи миграции. - Lexical rich text в Payload - редактор Lexical и модель узлов.
- Vercel Blob storage - объектное хранилище для коллекции
media. - Neon serverless Postgres - пулинг соединений и non-pooling URL.
- Drizzle ORM - слой миграций под Postgres-адаптером.
- Таблицы переводов WPML -
wp_icl_translationsи модельtrid.
Планируете похожий переезд - WordPress, Drupal или самописную CMS на Payload и Next.js - и хотите свежий взгляд на план миграции? Напишите мне.
