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 - і хочете свіжий погляд на план міграції? Напишіть мені.
