Skip to content

Миграция с WordPress на Payload CMS: практический гайд по продакшн-миграции (2026)

Если сделать всё правильно, миграция с WordPress - это не разовый экспорт, а скорее небольшой конвейер, который вы собираете и запускаете столько раз, сколько нужно. Ниже - продакшн-подход, которым я переносил мультиязычный сайт на Payload 3, PostgreSQL на Neon и Vercel: решения по стеку, конвертация HTML в Lexical, на которой держится любая миграция, и как я сохранил SEO, медиа и ссылочный вес.

Архитектура миграции WordPress на Payload CMS: чтение базы WordPress, конвертация HTML в Lexical и сидинг PostgreSQL на Neon
Автор:Опубликовано:Обновлено:Время чтения:16 min read

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, WPML trid и slug, чтобы пересобрать переводы и сохранить URL.
  • Контент и даты: base64 post_content, post_date и post_modified для точных меток публикации и обновления.
  • Ссылки на медиа: _thumbnail_id для главного изображения плюс regex-проход по инлайновым URL wp-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 засоряет всё подряд - &#8217; в прямой апостроф, пару типографских кавычек в прямые, &amp; в амперсанд - и полностью выбрасываем абзацы из одних пробелов и &nbsp;.

Пропуски так же намеренны, как и переносы: произвольные инлайн-стили и любая блочная структура за пределами заголовков и списков выкидываются, так что миграция заодно работает чисткой контента. Всё действительно более богатое - 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, так что внешние ссылки и позиции в поиске переносятся, а не ломаются.

Источники

Планируете похожий переезд - WordPress, Drupal или самописную CMS на Payload и Next.js - и хотите свежий взгляд на план миграции? Напишите мне.

Похожие статьи

Миграция на Next.js App Router с Pages Router: полный практический гайд (2026)

Практический гайд по миграции продакшн-приложений Next.js с Pages Router на App Router. Стоимостная модель гидратации RSC, механика модульного графа webpack и директивы 'use client', внутреннее устройство Data Cache, инкрементальная стратегия, разбор типичных ошибок и фреймворк принятия решений.

Next.jsArchitectureMigration
Читать статью

Архитектура веб-производительности: системный анализ 12 инженерных принципов (издание 2026)

Глубокий технический разбор веб-производительности 2026 года: физика сетей, конвейеры изображений, модели выполнения JavaScript, Critical Rendering Path, edge-вычисления, Core Web Vitals и продакшн RUM - с реальными бенчмарками и конкретными примерами реализации.

EngineeringArchitectureCore Web Vitals
Читать статью

React 19: useOptimistic, use(), Server Actions - механизм и архитектура (2026)

Детальный разбор пяти новых примитивов React 19: useOptimistic (конкурентная composition оптимистичного состояния с автоматическим rollback), use() (условное чтение Promise и Context), Server Actions (RPC-over-HTTP контракт и Progressive Enhancement), useActionState (стейт-threading паттерн), useFormStatus. Action-based mutation архитектура, которая заменяет Redux/Zustand для серверных мутаций.

React 19ArchitectureNext.js
Читать статью