BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Payload
  4. Complete 2026 WordPress to Payload Migration Guide

Complete 2026 WordPress to Payload Migration Guide

End-to-end ETL for ACF, media re-uploads, HTML→Lexical conversion, URL redirects, and SEO-safe Next.js cutover.

29th March 2026·Updated on:14th March 2026·MŽMatija Žiberna·
Payload
Complete 2026 WordPress to Payload Migration Guide

Need Help Making the Switch?

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.

Book Hourly Advisory

Migrating from WordPress to Payload CMS means extracting content from wp_posts HTML blobs, mapping ACF field groups to typed TypeScript collections, re-uploading your media library programmatically, converting raw HTML to Lexical JSON, and redirecting every legacy WordPress URL with Next.js. This guide walks through the entire process end to end: what data types you're working with, how to model them in Payload, how to write the ETL script, how to handle media and shortcodes, and how to cut over without dropping search rankings. If you want to understand the architecture trade-offs before committing, Payload CMS vs WordPress covers the stack shift in detail. If you've already decided and want help running the migration at scale, my Payload CMS migration service is the next step.


I took on my first WordPress-to-Payload migration for a client running a content-heavy site with seven years of posts, several ACF field groups, and a media library approaching 4,000 files. I assumed the hard part would be the infrastructure — setting up the Next.js app, configuring Payload collections, deploying to production. The infrastructure went fine. The hard part was the content. WordPress stores almost everything as unstructured HTML in a single wp_posts table, and getting that into Payload's typed, structured format takes real transformation work. This guide documents everything I learned, including the failure modes that cost me two days.


What You're Actually Migrating

Before writing a single line of the ETL script, you need a complete inventory of what exists in WordPress and what needs to happen to each type.

Posts and pages live in wp_posts with post_type values of post and page. The post_content column contains raw HTML — sometimes clean <p> tags, sometimes Gutenberg block comment markup, sometimes shortcode output from plugins that no longer exist. The post_status column tells you what's published (publish), drafted (draft), or in the trash (trash). You only want publish.

Custom post types are also rows in wp_posts with custom post_type values registered by themes or plugins. If you've been running ACF, your custom types will have corresponding meta rows in wp_postmeta keyed by ACF field keys.

ACF fields are stored as individual rows in wp_postmeta. Each field becomes a meta_key/meta_value pair. Simple fields (text, number, image ID) produce a single row. Repeaters and flexible content layouts serialize their structure into a PHP-style array that gets stored as a string — more on that in the failure points section.

The media library consists of wp_posts rows with post_type = 'attachment'. Each row has a corresponding _wp_attached_file meta entry in wp_postmeta containing the relative path within wp-content/uploads/. The actual files live on disk or in an S3 bucket depending on your hosting setup.

Users are in wp_users with roles stored in wp_usermeta. If you're migrating a multi-author site, you'll need a Payload users collection and a mapping between WordPress user IDs and Payload user document IDs.

Taxonomies — categories and tags — live in wp_terms, wp_term_taxonomy, and wp_term_relationships. Each post's taxonomy assignments are rows in wp_term_relationships.

Menus are stored in wp_posts as nav_menu_item post types with menu structure in wp_postmeta. Unless you're building a dynamic menu system in Payload, you'll likely hard-code these in your Next.js layout rather than migrating them.

wp_options is the global key-value store for WordPress settings, plugin configuration, and serialized PHP data structures. Do not blindly copy it. Extract only the specific values you need (site URL, blog name) and treat everything else as WordPress-internal.

Here's how each type maps to Payload:

WordPress typePayload equivalentMigration complexity
wp_posts (post)posts collectionMedium — HTML→Lexical conversion required
wp_posts (page)pages collectionMedium — same HTML issue, often has ACF
Custom post typesCustom collectionsMedium — depends on ACF field complexity
ACF simple fieldsPayload text, number, select fieldsLow
ACF repeatersPayload array fieldMedium
ACF flexible contentPayload blocks fieldHigh
wp_posts (attachment)media collectionMedium — requires file re-upload
wp_usersusers collectionLow
wp_termsSeparate collections or relationship fieldsLow
wp_optionsExtract manually as neededLow

Content Model Mapping: WordPress Post Types → Payload Collections

The content model is where you make the decisions that everything else depends on. Get this wrong and you'll be rewriting migration scripts halfway through.

A standard WordPress blog post maps straightforwardly to a Payload collection. Here's a complete TypeScript collection config for a posts collection that mirrors what WordPress stores:

// File: src/collections/Posts.ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'status', 'publishedAt'],
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      index: true,
    },
    {
      name: 'wordpressId',
      type: 'number',
      index: true,
      admin: {
        description: 'Original WordPress post ID — used for relationship fixups and delta imports',
      },
    },
    {
      name: 'content',
      type: 'richText',
    },
    {
      name: 'excerpt',
      type: 'textarea',
    },
    {
      name: 'featuredImage',
      type: 'upload',
      relationTo: 'media',
    },
    {
      name: 'categories',
      type: 'relationship',
      relationTo: 'categories',
      hasMany: true,
    },
    {
      name: 'author',
      type: 'relationship',
      relationTo: 'users',
    },
    {
      name: 'status',
      type: 'select',
      options: ['draft', 'published'],
      defaultValue: 'draft',
    },
    {
      name: 'publishedAt',
      type: 'date',
    },
    {
      name: 'seoTitle',
      type: 'text',
    },
    {
      name: 'seoDescription',
      type: 'textarea',
    },
  ],
}

The wordpressId field is critical. Every document you create in Payload during migration should carry the original WordPress ID. You'll use it for relationship fixups (linking posts to their categories after both are imported), delta imports (querying WordPress for posts modified after your initial ETL run), and debugging when something goes wrong mid-migration.

Mapping ACF field groups to Payload fields is where the real design work lives. Simple ACF fields map directly:

// File: src/collections/Projects.ts — ACF field group mapping examples
import type { CollectionConfig } from 'payload'

export const Projects: CollectionConfig = {
  slug: 'projects',
  fields: [
    // ACF text field → Payload text field
    { name: 'clientName', type: 'text' },

    // ACF image field → Payload upload field
    { name: 'heroImage', type: 'upload', relationTo: 'media' },

    // ACF repeater field → Payload array field
    {
      name: 'testimonials',
      type: 'array',
      fields: [
        { name: 'quote', type: 'textarea' },
        { name: 'author', type: 'text' },
        { name: 'role', type: 'text' },
      ],
    },

    // ACF flexible content → Payload blocks field
    {
      name: 'sections',
      type: 'blocks',
      blocks: [
        {
          slug: 'textBlock',
          fields: [
            { name: 'heading', type: 'text' },
            { name: 'body', type: 'richText' },
          ],
        },
        {
          slug: 'imageGrid',
          fields: [
            {
              name: 'images',
              type: 'array',
              fields: [{ name: 'image', type: 'upload', relationTo: 'media' }],
            },
          ],
        },
        {
          slug: 'callToAction',
          fields: [
            { name: 'heading', type: 'text' },
            { name: 'buttonText', type: 'text' },
            { name: 'buttonUrl', type: 'text' },
          ],
        },
      ],
    },
  ],
}

The ACF-to-Payload mapping follows a reliable pattern: simple scalar fields become Payload scalar fields, ACF repeater becomes a Payload array with the same sub-fields, and ACF flexible_content with its layout definitions becomes a Payload blocks field with one block per layout. The Payload wp-to-payload repository shows this side-by-side for a sample site, which is useful as a reference alongside this mapping.


The ETL Script: Extract, Transform, Load

With collections defined, you can write the migration script. I'll walk through extraction from the WordPress REST API, transformation to Payload's format, and loading via the Local API. For large sites (10,000+ posts), prefer a SQL dump as your source — it's faster and doesn't require keeping WordPress live throughout the migration. For most content sites, the REST API is simpler to work with.

First, set up the Payload client for standalone script usage. The Payload CMS SDK: CLI Toolkit article covers the shared authenticated client pattern in detail — the Local API initialization below follows that same approach:

// File: scripts/migrate-posts.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical'
import { JSDOM } from 'jsdom'

const WP_BASE_URL = process.env.WP_BASE_URL // e.g. https://old-site.com
const WP_PER_PAGE = 100

async function fetchWordPressPosts(page: number) {
  const url = `${WP_BASE_URL}/wp-json/wp/v2/posts?per_page=${WP_PER_PAGE}&page=${page}&_embed=1&status=publish`
  const res = await fetch(url)
  if (!res.ok) throw new Error(`WP REST error: ${res.status}`)
  const totalPages = Number(res.headers.get('X-WP-TotalPages') ?? 1)
  const posts = await res.json()
  return { posts, totalPages }
}

The REST API returns posts with content.rendered as an HTML string, featured_media as a media attachment ID, categories as an array of term IDs, and acf as an object of field values when the ACF REST API extension is active. The _embed=1 parameter inlines the featured media object so you can get the source URL without a second request.

Before you can convert content.rendered to Lexical, you need to upload all images referenced in that HTML to Payload first. The convertHTMLToLexical function from @payloadcms/richtext-lexical does not auto-upload images — it will silently drop any <img> tags that aren't annotated with the correct Payload data attributes. Handle media upload in a separate pass before converting content, then annotate the HTML.

// File: scripts/upload-media.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import path from 'path'
import fs from 'fs'
import { pipeline } from 'stream/promises'
import { createWriteStream } from 'fs'
import os from 'os'

// Map from WordPress attachment ID → Payload media document ID
const mediaIdMap = new Map<number, string>()

async function uploadWordPressMedia(
  payload: Awaited<ReturnType<typeof getPayload>>,
  wpMediaId: number,
  sourceUrl: string,
): Promise<string | null> {
  // Check if already uploaded
  const existing = await payload.find({
    collection: 'media',
    where: { wordpressId: { equals: wpMediaId } },
    limit: 1,
  })
  if (existing.docs.length > 0) {
    return existing.docs[0].id as string
  }

  // Download to temp file
  const tmpPath = path.join(os.tmpdir(), `wp-media-${wpMediaId}-${Date.now()}`)
  const res = await fetch(sourceUrl)
  if (!res.ok || !res.body) return null
  await pipeline(res.body as any, createWriteStream(tmpPath))

  const filename = path.basename(new URL(sourceUrl).pathname)

  try {
    const doc = await payload.create({
      collection: 'media',
      data: {
        alt: filename,
        wordpressId: wpMediaId,
      },
      filePath: tmpPath,
    })
    mediaIdMap.set(wpMediaId, doc.id as string)
    return doc.id as string
  } finally {
    fs.unlinkSync(tmpPath)
  }
}

Your media collection needs a wordpressId field (type number, indexed) — the same pattern as the posts collection. This lets you skip already-uploaded files on re-runs and perform relationship fixups.

With media uploaded and mediaIdMap populated, annotate image tags in the HTML before converting to Lexical:

// File: scripts/migrate-posts.ts (continued)

function annotateImagesInHtml(
  html: string,
  mediaIdMap: Map<number, string>,
  wpMediaByUrl: Map<string, number>,
): string {
  const dom = new JSDOM(html)
  const document = dom.window.document

  document.querySelectorAll('img').forEach((img) => {
    const src = img.getAttribute('src') ?? ''
    // Match the WordPress media ID from the URL if we have it
    const wpId = wpMediaByUrl.get(src)
    const payloadId = wpId ? mediaIdMap.get(wpId) : undefined

    if (payloadId) {
      img.setAttribute('data-lexical-upload-id', payloadId)
      img.setAttribute('data-lexical-upload-relation-to', 'media')
    }
  })

  return dom.serialize()
}

async function convertToLexical(html: string) {
  const editorConfig = await editorConfigFactory({})
  const { editorState } = await convertHTMLToLexical({
    html,
    editorConfig,
    JSDOM,
  })
  return editorState
}

Now tie extraction, transformation, and loading together:

// File: scripts/migrate-posts.ts (continued)

async function migratePosts() {
  const payload = await getPayload({ config })

  // First, build a map of WP category term ID → Payload category document ID
  // (assumes categories collection is already migrated)
  const categoryIdMap = await buildCategoryIdMap(payload)

  let page = 1
  let totalPages = 1

  while (page <= totalPages) {
    const { posts, totalPages: tp } = await fetchWordPressPosts(page)
    totalPages = tp

    for (const wpPost of posts) {
      // Skip if already migrated
      const existing = await payload.find({
        collection: 'posts',
        where: { wordpressId: { equals: wpPost.id } },
        limit: 1,
      })
      if (existing.docs.length > 0) {
        console.log(`Skipping WP post ${wpPost.id} — already migrated`)
        continue
      }

      // Annotate image HTML before Lexical conversion
      const annotatedHtml = annotateImagesInHtml(
        wpPost.content.rendered,
        mediaIdMap,
        wpMediaByUrl, // Map<url, wpMediaId> built during media upload pass
      )
      const lexicalContent = await convertToLexical(annotatedHtml)

      // Map category IDs
      const payloadCategoryIds = (wpPost.categories as number[])
        .map((id) => categoryIdMap.get(id))
        .filter(Boolean) as string[]

      // Map featured image
      const featuredImageId = wpPost.featured_media
        ? mediaIdMap.get(wpPost.featured_media)
        : undefined

      await payload.create({
        collection: 'posts',
        data: {
          title: wpPost.title.rendered,
          slug: wpPost.slug,
          wordpressId: wpPost.id,
          content: lexicalContent,
          excerpt: wpPost.excerpt.rendered.replace(/<[^>]+>/g, '').trim(),
          featuredImage: featuredImageId ?? null,
          categories: payloadCategoryIds,
          status: 'published',
          publishedAt: wpPost.date,
        },
      })

      console.log(`Migrated: ${wpPost.title.rendered} (WP ID: ${wpPost.id})`)
    }

    page++
  }

  await payload.destroy()
  console.log('Migration complete.')
}

migratePosts().catch(console.error)

Run this with tsx scripts/migrate-posts.ts — the CLI toolkit article covers the tsx + tsconfig.paths.json setup required to resolve @payload-config in standalone scripts.

For sites with thousands of posts, processing them in sequence is slow. The bulk data import with Payload Queues article covers the queue-based pattern for large-volume imports — the same approach applies here, with each WP post ID dispatched as a job task.


Media Migration: Re-uploading the WordPress Media Library

The script above uploads media on-demand during post migration. For a complete media library migration — including files not directly embedded in post content — you need a dedicated media pass first.

The WordPress REST API exposes all attachments at /wp-json/wp/v2/media. Each record includes a source_url for the full-size file and a media_details object with the available image sizes:

// File: scripts/migrate-media.ts
import { getPayload } from 'payload'
import config from '@payload-config'

async function fetchAllWpMedia(baseUrl: string) {
  const allMedia: any[] = []
  let page = 1
  let totalPages = 1

  while (page <= totalPages) {
    const res = await fetch(
      `${baseUrl}/wp-json/wp/v2/media?per_page=100&page=${page}`,
    )
    totalPages = Number(res.headers.get('X-WP-TotalPages') ?? 1)
    const batch = await res.json()
    allMedia.push(...batch)
    page++
  }

  return allMedia
}

async function migrateMediaLibrary() {
  const payload = await getPayload({ config })
  const wpMedia = await fetchAllWpMedia(process.env.WP_BASE_URL!)

  for (const item of wpMedia) {
    const existing = await payload.find({
      collection: 'media',
      where: { wordpressId: { equals: item.id } },
      limit: 1,
    })
    if (existing.docs.length > 0) continue

    // Download to temp, then create in Payload
    const payloadId = await uploadWordPressMedia(payload, item.id, item.source_url)
    if (payloadId) {
      console.log(`Uploaded: ${item.source_url} → Payload ID ${payloadId}`)
    }
  }

  await payload.destroy()
}

migrateMediaLibrary().catch(console.error)

Payload won't replicate WordPress's media URL structure (e.g., /wp-content/uploads/2023/04/image.jpg). Uploaded files get Payload's own path structure. Accept this early and plan your redirect rules to cover /wp-content/uploads/ paths pointing to Payload's new media URLs. This is also why the wordpressId field on media documents is essential — it lets you build a mapping table for any legacy media URLs embedded in non-Lexical content.


Shortcodes and HTML That Won't Survive Conversion

content.rendered from the WordPress REST API is the post-processed HTML — shortcodes are already evaluated by the time the API returns them. This sounds helpful, until you look at what the shortcodes actually rendered. Plugin-generated tables. Slider markup. Contact form placeholders. Payment button HTML. None of it maps to Lexical nodes, and convertHTMLToLexical will silently discard markup it can't parse.

The realistic approach depends on your content's HTML complexity:

Content typeRecommended approach
Standard blog posts (headings, paragraphs, lists, images)convertHTMLToLexical — works cleanly after image annotation
Posts with simple shortcodes (buttons, callouts)Parse and replace shortcode output with clean HTML before conversion
Posts with page builder markup (Elementor, Divi)Staged migration — store as raw HTML, convert post-launch
Posts with plugin-specific shortcodes that no longer renderStrip and flag for manual editorial review

The staged migration approach stores the original HTML in a custom field and renders it via dangerouslySetInnerHTML on the front end, giving you a working site on day one while you migrate content to structured Lexical incrementally:

// File: src/collections/Posts.ts — staged migration variant
{
  name: 'legacyHtml',
  type: 'textarea',
  admin: {
    description: 'Raw HTML from WordPress — render via dangerouslySetInnerHTML until migrated to richText',
    condition: (data) => Boolean(data.legacyHtml),
  },
},
{
  name: 'contentMigrated',
  type: 'checkbox',
  defaultValue: false,
  admin: {
    description: 'Set to true once legacyHtml has been converted to the richText field',
  },
},

In your Next.js page component, check contentMigrated to decide which field to render. Once a post is fully converted, the legacyHtml field can be cleared.

The community package @teagantb/payload-wordpress-migration provides WordPress XML import, REST API import, and block-to-Lexical conversion via an Admin UI. Worth evaluating for your specific content mix before building custom shortcode parsers.

Gutenberg block comment delimiters (<!-- wp:paragraph -->) appear verbatim in post_content when queried directly from the database or via the REST API's content.raw field. Always use content.rendered from the REST API, which returns the evaluated HTML output. If you're working from a SQL dump, run content through WordPress's apply_filters('the_content', $post_content) before exporting — or use the REST API as your extraction source.


SEO and URL Preservation: 301 Redirect Strategy

WordPress supports several permalink structures. The most common ones you'll encounter and their Next.js redirect equivalents:

Date-based (/2024/03/my-post/) — WordPress's default for many sites:

// File: next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async redirects() {
    return [
      // Date-based: /2024/03/my-post/ → /blog/my-post
      {
        source: '/:year(\\d{4})/:month(\\d{2})/:slug',
        destination: '/blog/:slug',
        permanent: true,
      },
      // Category-prefixed: /news/my-post/ → /blog/my-post
      {
        source: '/news/:slug',
        destination: '/blog/:slug',
        permanent: true,
      },
      // WordPress media library paths → Payload media URLs
      // If you can build a static mapping, use individual redirects
      // For large libraries, handle in Next.js middleware instead
      {
        source: '/wp-content/uploads/:path*',
        destination: '/api/media-redirect/:path*',
        permanent: false, // Keep as 302 until all URLs are verified
      },
    ]
  },
}

export default nextConfig

The :year(\\d{4}) and :month(\\d{2}) syntax uses path-to-regexp regex constraints — Next.js supports this natively. The captured :slug parameter carries through to the destination.

For category-prefixed URLs, you'll need one redirect entry per category unless all categories follow the same pattern. If you have dozens of categories, generate the redirects array programmatically by querying your Payload categories collection and building the array at build time.

WordPress pagination (/page/2/, /page/3/) and archive URLs (/category/news/, /tag/typescript/, /author/matija/) don't exist in Payload by default. Redirect them to the closest equivalent pages you've built in your Next.js app, or to the homepage if no equivalent exists:

// File: next.config.ts (additional redirect entries)
{
  source: '/page/:pageNum',
  destination: '/blog',
  permanent: true,
},
{
  source: '/category/:category',
  destination: '/blog',
  permanent: true,
},
{
  source: '/tag/:tag',
  destination: '/blog',
  permanent: true,
},
{
  source: '/author/:author',
  destination: '/',
  permanent: true,
},
// WordPress feed URLs
{
  source: '/feed',
  destination: '/rss.xml',
  permanent: true,
},

Before going live, run your full WordPress URL list through a redirect checker. Export all published post URLs from WordPress (Screaming Frog or a SQL query on wp_posts where post_status = 'publish') and verify every one returns a 301 in your Payload + Next.js setup. Missing redirects on high-authority URLs are the fastest way to lose search rankings during cutover.


Content Freeze and Cutover

The content freeze is the moment you stop WordPress from accepting new content and begin the final delta import. Done correctly, you lose no content and minimize the DNS switch window.

Step 1 — Freeze WordPress publishing. Put WordPress in maintenance mode or disable editing for all users except your migration account. The simplest approach is a plugin like WP Maintenance Mode, or adding define('DISALLOW_FILE_EDIT', true) and removing editor roles temporarily.

Step 2 — Run the full ETL. Execute your migration scripts in order: media first, then flat collections (categories, tags, users), then posts and pages (which reference the flat collections via relationship fields), then any CPTs. Log every WordPress ID you migrate successfully.

Step 3 — Relationship fixup pass. Query every Payload document and verify that relationship fields (featuredImage, categories, author) are populated. The wordpressId field on each document is your key — query WordPress for the relationship IDs, look them up in Payload by wordpressId, and patch any gaps with payload.update().

Step 4 — Delta import. The initial ETL might take hours on a large site. By the time it finishes, WordPress may have received new content (before the freeze) or you may have missed a status filter. Query the WordPress REST API for posts modified after your ETL start timestamp:

// File: scripts/delta-import.ts
const etlStartTime = '2026-03-01T00:00:00' // ISO timestamp of initial ETL run

const res = await fetch(
  `${WP_BASE_URL}/wp-json/wp/v2/posts?modified_after=${etlStartTime}&per_page=100&status=publish`,
)
const newOrModifiedPosts = await res.json()
// Re-run the same create/update logic, using wordpressId to detect existing docs

Step 5 — Smoke test. Verify a sample of 20–30 posts across different content types. Check that rich text renders correctly, images load, relationships are populated, and slugs match what you've configured in Next.js routes.

Step 6 — DNS switch. Point your domain to the new Payload + Next.js deployment. Keep the WordPress instance accessible (ideally on a subdomain) for at least 48 hours after cutover. convertHTMLToLexical downloads images from the original URL during migration — if any media uploads failed and you need to re-run them, you'll need the old site reachable.

Step 7 — Monitor Search Console. Watch for crawl errors in Google Search Console over the first two weeks. Missing redirects show up as 404s on URLs that previously had inbound links. Fix them as they appear.

For infrastructure considerations during and after cutover, the Payload CMS + Next.js self-hosted deployment guide covers the production setup.


Common Failure Points

Serialized PHP in wp_options. WordPress plugins store configuration as PHP-serialized strings. If you extract wp_options directly from the database, you'll encounter values like a:3:{i:0;s:4:"text";}. PHP serialization is not JSON — never attempt to parse these as JSON. Extract only the specific option keys you need by name (e.g., blogname, blogdescription, siteurl) and ignore the rest.

Orphaned wp_postmeta rows. Deleting posts in WordPress leaves wp_postmeta rows behind. If your SQL query joins wp_postmeta to wp_posts, you may encounter meta rows with no matching post. Filter on wp_posts.post_status = 'publish' and use an INNER JOIN to exclude orphaned meta.

ACF repeater and flexible content serialization. When ACF stores complex field types, it writes the field values as individual rows in wp_postmeta with numbered key suffixes (e.g., testimonials_0_quote, testimonials_0_author, testimonials_1_quote). There's also a testimonials key containing the count. The REST API's acf object deserializes these into a proper array automatically. If you're working from a SQL dump, you'll need to reconstruct the array from the numbered rows yourself.

WooCommerce data. If your WordPress site runs WooCommerce, treat the product, order, and customer data as a completely separate migration scope. WooCommerce stores its data across wp_posts, wp_postmeta, and several custom tables (woocommerce_order_items, woocommerce_order_itemmeta, etc.). Migrating WooCommerce to Payload's ecommerce architecture is a distinct engagement from migrating editorial content.

Page builder markup. If the WordPress site used Elementor, Divi, Beaver Builder, or WPBakery, the post_content field contains proprietary shortcode or JSON syntax specific to that builder. content.rendered from the REST API outputs the rendered HTML, which is usable but often heavily nested and will not convert cleanly to Lexical blocks. Use the staged migration approach for these posts.

_wp_attachment_metadata and custom image sizes. WordPress generates multiple image sizes for every upload (thumbnail, medium, large, custom). Payload's media handling works differently — you define image sizes in your collection config and Payload generates them on upload. You don't need to migrate the cropped variants, only the original files. Let Payload regenerate sizes from the originals.


FAQ

Do I need to keep WordPress running during the migration?

For the media upload pass, yes — the ETL script downloads media files directly from their WordPress URLs. If you're working from a SQL dump rather than the REST API, you still need file access for the binary uploads. The simplest approach is keeping WordPress on a subdomain (e.g., legacy.yourdomain.com) until the migration is confirmed complete.

How do I handle WordPress multisite?

Multisite adds a site ID prefix to all tables (wp_2_posts, wp_2_postmeta). The migration logic is identical — you just parameterize the table prefix per site and run the ETL once per site into separate Payload collections or a multi-tenant Payload setup.

What happens to WordPress user passwords?

WordPress hashes passwords using a custom PHPass implementation that's incompatible with standard bcrypt. You cannot migrate WordPress password hashes into Payload directly. The cleanest approach: migrate user records without passwords, then trigger a password reset email for all users on launch day.

Can I run the ETL script incrementally without a full re-run?

Yes — the wordpressId field and the modified_after REST API parameter are what make this possible. On each script run, check for an existing Payload document with the matching wordpressId before creating. Use payload.update() for documents that already exist. This means you can stop and resume the ETL at any point without duplicating data.

How long should I keep the 301 redirects in next.config.ts?

Permanently. There's no cost to keeping redirect rules in next.config.ts, and removing them risks breaking inbound links from external sites that haven't updated their references. Treat legacy WordPress URL redirects as permanent infrastructure.


Doing This at Scale or With a Tight SEO Deadline?

This guide covers the full technical path for a WordPress-to-Payload migration: content modeling, ETL scripting, media re-upload, HTML-to-Lexical conversion, shortcode handling, URL redirects, and cutover sequencing. Running this against a large site — heavy ACF usage, thousands of posts, complex permalink structures, SEO-critical URLs — adds significant scoping, QA, and testing work on top of what's covered here.

My Payload CMS migration service is exactly this work. If you want a principal-led migration with a realistic timeline and a clean SEO handoff, that's where to start.


Conclusion

WordPress-to-Payload is a full stack shift, and the content layer is where most migrations get stuck. The wp_posts HTML blob problem, the convertHTMLToLexical image annotation requirement, the ACF field mapping decisions, the redirect coverage — each of these is a real source of project delay if you hit it unprepared. The approach in this guide — media-first migration, wordpressId mapping on every document, staged HTML handling for complex content, and a freeze + delta-import cutover — gives you a repeatable process that you can stop, resume, and verify at each step.

If you have questions about specific parts of your migration, drop them in the comments below.

Thanks, Matija

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Table of Contents

  • What You're Actually Migrating
  • Content Model Mapping: WordPress Post Types → Payload Collections
  • The ETL Script: Extract, Transform, Load
  • Media Migration: Re-uploading the WordPress Media Library
  • Shortcodes and HTML That Won't Survive Conversion
  • SEO and URL Preservation: 301 Redirect Strategy
  • Content Freeze and Cutover
  • Common Failure Points
  • FAQ
  • Doing This at Scale or With a Tight SEO Deadline?
  • Conclusion
On this page:
  • What You're Actually Migrating
  • Content Model Mapping: WordPress Post Types → Payload Collections
  • The ETL Script: Extract, Transform, Load
  • Media Migration: Re-uploading the WordPress Media Library
  • Shortcodes and HTML That Won't Survive Conversion
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Projects
  • How I Work
  • Blog
  • RSS Feed
  • Services

    • Payload CMS Websites
    • Bespoke AI Applications
    • Advisory

    Payload

    • Payload CMS Websites
    • Payload CMS Developer
    • Audit
    • Migration
    • Pricing
    • Payload vs Sanity
    • Payload vs WordPress
    • Payload vs Strapi
    • Payload vs Contentful

    Industries

    • Manufacturing
    • Construction

    Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Book a discovery callContact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved