Migrating from WordPress to Payload CMS: a high-level playbook
WordPress is the path of least resistance to get a content site online, and the path of most resistance once you outgrow it. This playbook maps the migration in five stages — extract, model, transform, load, redirect — and the rules at each step that keep content and SEO intact.
WordPress is the path of least resistance for getting a content site online. It becomes the path of most resistance once you outgrow it. At some point the plugin sprawl, the wp_postmeta key-value soup, and the rigid templating start costing more than they save, and a move to a code-first, type-safe CMS like Payload starts to look attractive.
We’ve run this migration enough times to have a map of it, and this is that map. It’s deliberately generic (no project-specific schemas), and the Payload snippets are illustrative: they show shape, not code to paste into your repo.
We run every WordPress → Payload migration the same way, in this order:
| # | Step | What it is | The one rule |
|---|---|---|---|
| 1 | Extract | Pull data out of WordPress (WXR/XML, DB, or REST/WooCommerce CSV) into flat JSON on disk | Extract once, transform a hundred times. Never transform live against WP |
| 2 | Model | Design the typed Payload target: one collection per post_type, real fields instead of meta soup | Carry a legacyWpId on every doc so you can wire relationships and redirects later |
| 3 | Transform | Map the flat JSON onto your collection shapes: HTML → Lexical, taxonomies → relationships | Make the loader idempotent: upsert on the legacy key, never blind-create |
| 4 | Load | Write into Payload in dependency order: media → taxonomies → content → relationship pass | Disable afterChange side effects (revalidation, search, webhooks) during bulk load |
| 5 | Redirect | Map old permalinks → new paths; 301 what moved, 410 what’s gone | Don’t break indexed URLs. Everyone underestimates this step |
The rest of the article walks each step in detail.
Why this is harder than “export and import”
The naive mental model is: dump WordPress, load it into Payload, done. In practice the work is in the impedance mismatch between the two systems:
| WordPress | Payload |
|---|---|
Everything is a post with a post_type discriminator | Distinct, strongly-typed collections |
Fields live in wp_postmeta as untyped key/value rows | Typed fields on a config, validated on write |
Content is one HTML blob (post_content) | Structured Lexical rich text (JSON) or blocks |
| Relationships are IDs buried in meta or taxonomy tables | First-class relationship fields with referential checks |
Media is files + wp_attachments rows | An upload-enabled collection with its own storage adapter |
URLs are permalinks shaped by .htaccess rules | Whatever routing you build in your frontend |
Most of the work is reshaping data across that table, plus the unglamorous but critical job of not breaking existing URLs. We treat it as an ETL project, and we plan the hours accordingly.
The steps in detail
1. Extract
First we get the data out of WordPress and into something we can iterate on offline. Three common sources, in rough order of fidelity:
- The WXR/XML export (
Tools → Export). Easy to get, but lossy: it flattens custom fields and often mangles serialized PHP. Fine for posts/pages, weak for e-commerce. - Direct database access (
wp_posts,wp_postmeta,wp_terms, …). The most complete source. You query the relations yourself and lose nothing. - The REST API (
/wp-json/wp/v2/...) or WooCommerce CSV exports for shop data. Convenient and already JSON-ish, but paginated and rate-limited.
Whatever the source, the goal of this step is always the same: flat JSON files on disk we can re-parse a hundred times without touching WordPress again.
// extract: WXR/XML -> normalized JSON on disk
import { XMLParser } from 'fast-xml-parser'
import { readFile, writeFile } from 'node:fs/promises'
const xml = await readFile('export.wordpress.xml', 'utf8')
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml)
const items = parsed.rss.channel.item.map((item) => ({
wpId: item['wp:post_id'],
type: item['wp:post_type'], // post | page | product | attachment ...
status: item['wp:status'], // publish | draft | trash
slug: item['wp:post_name'],
title: item.title,
contentHtml: item['content:encoded'],
// wp:postmeta is the untyped soup: flatten the keys you care about
meta: Object.fromEntries(
[item['wp:postmeta']].flat().filter(Boolean).map((m) => [m['wp:meta_key'], m['wp:meta_value']]),
),
}))
await writeFile('data/posts.json', JSON.stringify(items, null, 2))
Rule of thumb: never let your transform code talk to WordPress directly. Extract once, commit the JSON, transform repeatedly. It makes the whole thing replayable and reviewable.
2. Model
Before transforming anything, we design the target. This is the part WordPress never made you do, and it’s where most of the long-term value of the migration comes from.
For each WordPress post_type, we decide what it becomes in Payload (usually its own collection) and what its fields are. The shift in mindset is simple: stop storing untyped meta, start declaring typed fields.
// model: a Payload collection, the typed target for "post"
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: { useAsTitle: 'title' },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true, index: true },
{ name: 'content', type: 'richText' }, // Lexical JSON, not HTML
{ name: 'featuredImage', type: 'upload', relationTo: 'media' },
{ name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true },
{ name: 'publishedAt', type: 'date' },
// carry the old id during migration so you can resolve relationships later
{ name: 'legacyWpId', type: 'number', index: true, admin: { readOnly: true } },
],
}
Two modeling habits pay off enormously:
- Keep a
legacyWpIdfield. Relationships in WordPress are expressed as old IDs. You can’t resolve “this post’s category” until both sides exist in Payload, so we import everything carrying its old ID, then do a second pass to wire relationships by looking the old IDs up. Drop the field once you’re done, or keep it for redirects (see step 5). - Split the HTML blob.
post_contentis one field in WordPress. In Payload it’s often worth decomposing into structured blocks (hero, gallery, quote, callout) so editors get a real page builder instead of a wall of HTML. That decomposition is optional, but it’s the single biggest “why did we bother” payoff.
3. Transform
Now we map the flat JSON onto the collection shapes. Two conversions are persistently annoying:
HTML → Lexical rich text. Payload’s editor stores a JSON tree, not HTML. We convert the WordPress HTML into that tree (Payload ships an editorConfigFactory / convertHTMLToLexical-style helper for exactly this). We budget real time here: WordPress HTML is full of shortcodes, inline styles, and embeds that don’t map cleanly.
Taxonomies → relationships. wp_terms + wp_term_relationships become Payload relationship fields. We import the taxonomies as their own collections first, then resolve.
// transform + load: idempotent upsert keyed on the legacy id
import { getPayload } from 'payload'
import config from '@payload-config'
const payload = await getPayload({ config })
for (const item of posts) {
const existing = await payload.find({
collection: 'posts',
where: { legacyWpId: { equals: item.wpId } },
limit: 1,
})
const data = {
title: item.title,
slug: item.slug,
content: htmlToLexical(item.contentHtml), // your HTML->Lexical step
publishedAt: item.publishedAt,
legacyWpId: item.wpId,
}
if (existing.docs[0]) {
await payload.update({ collection: 'posts', id: existing.docs[0].id, data })
} else {
await payload.create({ collection: 'posts', data })
}
}
Make the loader idempotent: upsert on
legacyWpId, never blind-create. You will run it more than once, and a re-run should converge, not duplicate.
4. Load (in the right order, with hooks disabled)
Loading order matters because of relationships: media and taxonomies before the content that references them.
media → categories / tags → posts / pages → resolve relationships (2nd pass)
Two operational notes for bulk loads:
- Media first, by streaming the binaries. We point Payload’s upload at the old file URLs and let it pull them into the storage adapter (S3, local, etc.). Keep the old attachment ID on each media doc so featured-image references resolve later.
- Disable side effects during the import. Payload runs
afterChangehooks (cache revalidation, search indexing, webhooks) on every write. During a 10,000-row bulk load that’s 10,000 cache busts you don’t want. Pass a context flag and short-circuit your hooks:
await payload.create({
collection: 'posts',
data,
context: { disableRevalidate: true }, // your hooks check this and bail early
})
5. Redirect: the step everyone underestimates
Your old WordPress URLs are indexed by Google, linked from other sites, and bookmarked by users. If /2019/03/my-post/ 404s after launch, you lose that traffic and that SEO.
We build a redirect map from old permalink → new path, serving 301 (permanent) for moved content and 410 (gone) for anything we intentionally dropped. This is exactly where the legacyWpId and old-slug data we carried through pays off.
// next.config or middleware: 301 old WP permalinks to new routes
export const redirects = async () => [
{ source: '/:year(\\d{4})/:month(\\d{2})/:slug', destination: '/blog/:slug', permanent: true },
{ source: '/?p=:id', destination: '/blog/:slug', permanent: true }, // resolved via legacy id map
]
Don’t skip the 410s. Telling search engines a page is intentionally gone is much cleaner than letting a thousand soft-404s rot in Search Console.
Localization
WordPress has no native multilingual model: translations live in a plugin. WPML and Polylang both store each translation as its own wp_posts row, linked into a translation group through a side table (icl_translations for WPML, term-based links for Polylang) with one language flagged as the original. Payload localizes the other way. It localizes at the field level: every locale’s value lives under one document ID, selected by a locale argument on each write. So the migration collapses WordPress’s separate per-language posts onto per-locale writes of the same document.
We group the translated posts by their translation group and import the original language first: that write creates the base document. Then we layer each additional language onto the same document ID, one locale at a time. We map the plugin’s language codes onto our Payload locale identifiers up front, and fall back to the original language when a translation is missing.
The localized-array trap. Payload localizes fields, not arrays. If a post has a repeating field (blocks, a gallery, FAQ rows) and you write that array once per locale, each write overwrites the whole array instead of merging, so the second locale silently wipes the localized values the first locale wrote inside those rows. The rule that avoids it: read the current document with locale: "all" to get every locale’s data; match existing rows by a stable key (the slug, the legacyWpId) and carry their row id forward, since rows written without an id are recreated on each pass and drop prior locale data; and do one write per locale that carries the full merged state, all scalar fields and the complete array together, never a second follow-up write for the array alone.
// 1) Original language first: this write creates the base document
const base = await payload.create({
collection: 'posts',
locale: defaultLocale, // e.g. "en"
data: mapPost(originalPost), // scalar fields + array rows
})
idMap.set(originalPost.wpId, base.id)
// 2) Layer each translation onto the SAME document id
for (const translation of otherLanguages) {
// Snapshot every locale so we don't clobber the others
const current = await payload.findByID({
collection: 'posts',
id: base.id,
locale: 'all',
})
// Match existing rows by a stable key and carry their id forward
const rowIds = new Map(
(current.blocks ?? []).map((r) => [r.legacyKey, r.id]),
)
const blocks = mapRows(translation).map((r) => ({
...(rowIds.has(r.legacyKey) ? { id: rowIds.get(r.legacyKey) } : {}),
...r,
}))
// One write per locale, carrying the full merged state
await payload.update({
collection: 'posts',
id: base.id,
locale: toPayloadLocale(translation.language), // e.g. "pl", "de"
data: { ...mapScalars(translation), blocks },
context: { disableRevalidate: true },
})
}
A pragmatic checklist
- Extract to flat JSON on disk; never transform live against WordPress.
- Model each
post_typeas a typed collection before writing transform code. - Carry a
legacyWpId(and old slug) on every doc for relationship and redirect resolution. - Convert
post_contentHTML to Lexical (or decompose into blocks). - Make the loader idempotent: upsert on the legacy key.
- Load in dependency order: media → taxonomies → content → relationship pass.
- Disable revalidation/search/webhook hooks during the bulk load.
- Map plugin translation groups onto per-locale writes: import the original language first, then layer translations onto the same document ID; never write a repeating field once per locale without merging.
- Build a 301/410 redirect map from old permalinks; verify in staging.
- Diff counts (WP rows vs Payload docs) and spot-check rendered pages before cutover.
When to stay on WordPress
Not every WordPress site should move, and it is worth being honest about when it should not. WordPress earns its keep when the site is content-simple and the people running it are not engineers: a blog, a brochure site, or a marketing site whose team leans on the plugin ecosystem and the familiar editor. If a handful of well-maintained plugins cover what you need and nobody wants to own a codebase, WordPress is doing its job, and a migration adds cost and engineering you do not need.
The case for Payload starts when you are already working against WordPress: the content model has outgrown posts and meta, you maintain custom plugins to force structure onto it, editing is slowed by plugin sprawl, or you want the content in a typed, version-controlled schema your team can build against. That is the point where moving the content into code earns back what the migration costs.
What you get on the other side
Once the data is in Payload, the wins compound: editors work against typed fields instead of meta soup, content is structured JSON you can render anywhere (web, native, email), and your schema lives in version control where it can be reviewed and migrated like any other code. The migration is a one-time tax. Paying it buys you a CMS that behaves like part of your codebase instead of a black box you integrate around.
How WAYF can help
We are an official Payload partner and Top Contributor, and we have run this migration on real WordPress sites. If you are weighing a move off WordPress, book a call and we will tell you whether Payload earns the change or whether WordPress still fits. See our work for platforms we have built.
We're booking content platform
engagements for 2026.
Twenty-five minutes to walk through the work and decide if we're the right team for it. Scoping and a fixed price come after.