Migrate from WordPress to Payload
A working reference for moving a WordPress estate onto Payload: the ACF field-by-field mapping table, the four export paths and when each fits, the block-to-Lexical transformation, and the permalink work that keeps the rankings.
Payload Partner · Top Contributor We build and maintain Payload plugins used by the wider community. Direct access to the maintainers when something needs attention.A WordPress estate holds its structure in two places the export screen barely shows: the custom fields hanging off wp_postmeta, and the block markup buried inside post_content. Everything else moves in an afternoon. This page covers the field model first, then the two transformations that carry the budget.
WAYF is a Payload Partner agency and a top contributor to its open source. The mechanics below are how the migration runs in practice. For the five-stage narrative of the same project, the WordPress to Payload playbook covers extract, model, transform, load, and redirect end to end.
Field mapping
Each WordPress post_type becomes a Payload collection. The fields inside it come from Advanced Custom Fields on most estates, so the ACF field type is the unit that matters. The table covers ACF’s complete set.
| ACF field type | Payload field | Notes |
|---|---|---|
| Text | text | Direct |
| Text Area | textarea | Direct |
| Number | number | Direct |
| Range | number | Carry the min/max as validation |
email | Direct | |
| URL | text | Payload has no dedicated URL type; validate on write |
| Password | — | Leave behind |
| Select | select | ACF choices become options |
| Checkbox | select, hasMany: true | ACF’s checkbox is multi-value |
| Radio Button | radio | Direct |
| Button Group | radio | Same data, different admin control |
| True / False | checkbox | Direct |
| Image | upload | Migrate media first |
| File | upload | Same |
| Gallery | upload, hasMany: true | The sequence is editor-set; preserve it |
| Wysiwyg Editor | richText | HTML in, Lexical out; see below |
| oEmbed | text | Store the URL; the embed renders at the front end |
| Date Picker | date | Direct |
| Date Time Picker | date | Direct |
| Time Picker | date | Direct |
| Color Picker | text | Hex string |
| Google Map | point plus group | Coordinates to point, address parts to a group |
| Icon Picker | text or select | Depends on whether the set is fixed |
| Repeater | array | Row for row |
| Flexible Content | blocks | Each layout becomes a block |
| Group | group | Direct |
| Clone | — | Inline the cloned fields at the target |
| Accordion | — | Presentational; use collapsible |
| Tab | — | Presentational; use tabs |
| Post Object | relationship | Single |
| Relationship | relationship, hasMany: true | Multiple |
| Taxonomy | relationship | Points at the collection the taxonomy became |
| User | relationship | Points at the users collection |
| Link | group | The Link Array return format gives url, title, target |
| Page Link | text or relationship | Relationship where the target is in Payload |
Four rows hide the work: Wysiwyg, Gallery, Flexible Content, and anything Relational. The rest is a value copy.
How ACF stores values in wp_postmeta
ACF writes each value into wp_postmeta under the field name, then writes a second row under the same name prefixed with an underscore, holding the field key:
meta_key | meta_value |
|---|---|
subtitle | A working reference |
_subtitle | field_64a1f0c9d3e21 |
The underscore row is how ACF knows which field definition a value belongs to. A transform that reads wp_postmeta blind will pick up both rows, so filter the ones starting with an underscore before mapping anything.
Repeaters flatten into the same table. The parent row holds the row count and each sub-value gets a composed key of parent_index_child:
meta_key | meta_value |
|---|---|
staff_members | 2 |
staff_members_0_name | Ada |
staff_members_0_thumbnail | 412 |
staff_members_1_name | Grace |
Reassembling an array field means reading the count, then looping the index to collect each sub-key. Flexible Content adds a layout name per row on top of that. Field group definitions themselves live as acf-field-group posts. Read the field types from there when nobody can find the original PHP.
Getting the content out
Four paths, and the one that fits depends on what holds your structure.
WXR export. Tools → Export in the admin, or wp export on the command line. It produces WordPress’s own XML, and it carries posts, pages, taxonomies, and wp:postmeta rows. Custom field values arrive flattened, and anything WordPress stored as serialized PHP arrives as a serialized PHP string that needs unserializing before it means anything. The command takes filters worth knowing:
wp export \
--dir=./export \
--post_type=post,page,product \
--with_attachments \
--max_file_size=50
--skip_comments drops comment data, --post__in limits the run to specific IDs, and --start_date / --end_date bound it by publication date. The file splits at 15 MB unless --max_file_size says otherwise.
WP-CLI against the database. wp db export gives the complete picture, including the tables WXR never touches. Use it when the estate has WooCommerce data, plugin tables, or a meta structure the XML flattens past recognition.
The REST API. /wp-json/wp/v2/posts and friends return JSON already, so this is the least code to get started. per_page caps at 100. The total count arrives in the X-WP-Total and X-WP-TotalPages response headers, so you write the pagination loop yourself. ACF values are absent by default. Field groups have to opt in through the Show in REST API setting, available since ACF 5.11, and the values then appear under an acf object on each record.
Direct SQL. wp_posts, wp_postmeta, wp_terms, wp_term_taxonomy, wp_term_relationships, wp_users, wp_usermeta, wp_comments. The most complete source and the most work, since you resolve every join yourself. It is the right call when the estate is large enough that a full XML export is unwieldy.
Whichever path you take, land the result on disk and transform from there.
Gutenberg to Lexical
WordPress stores block content inside post_content as HTML wrapped in comment delimiters:
<!-- wp:heading {"level":2} -->
<h2>Field mapping</h2>
<!-- /wp:heading -->
parse_blocks() turns that string into a tree. Each node carries blockName (core/heading), attrs (the JSON from the delimiter), innerBlocks, innerHTML, and innerContent, which marks where the child blocks sit among the raw fragments. Payload’s Lexical editor stores its own JSON tree, so the transform walks one and emits the other.
The core blocks map cleanly:
| Gutenberg block | Lexical node |
|---|---|
core/paragraph | paragraph |
core/heading | heading, tag from the level attribute |
core/list and core/list-item | list and listitem |
core/quote | quote |
core/pullquote | quote, or a custom block to keep the styling |
core/image | upload node pointing at the migrated media doc |
core/gallery | Custom block wrapping several uploads |
core/embed | Custom block holding the provider and URL |
core/code and core/preformatted | code |
core/separator | horizontalrule |
core/table | Custom block |
core/columns and core/column | Custom block, or flatten |
core/group | Unwrap, or a custom block where the grouping matters |
The exceptions:
core/htmlandcore/shortcodehold arbitrary markup and plugin calls. There is no target node for either. Decide per shortcode whether it becomes a Payload block, gets rendered ahead of the migration and carried as static HTML, or gets dropped.- Third-party blocks. Any
blockNameoutside thecore/namespace came from a plugin, and its markup shape is that plugin’s business. Inventory them before scoping: a site with eight custom block types is a different project from one with forty. - Classic-editor content.
has_blocks()tells you which posts carry block markup and which are plain HTML. Those hold plain HTML with inline styles and shortcodes, and go through an HTML-to-Lexical path instead of the block walker. Estates that migrated to Gutenberg partway through hold both, so both paths run.
Across every row above, the delimiters carry attributes the Lexical nodes have no slot for: alignment, colour, a custom class name. Decide per block type which of those become fields on a Payload block and which go, because the walker drops silently whatever it is not told to map.
Media
WordPress keeps attachments as wp_posts rows with post_type set to attachment, and the parts you need are spread across three meta keys:
| Meta key | What it holds |
|---|---|
_wp_attached_file | The file path relative to wp-content/uploads |
_wp_attachment_metadata | Width, height, file, the generated sizes array, and image_meta |
_wp_attachment_image_alt | The alt text |
Alt text lives in its own key, so migrations drop it. Read it explicitly; accessibility and image search both depend on it. On the Payload side it needs a home: an upload-enabled collection adds filename, mimeType and filesize by itself, and alt is a field you declare.
The generated sizes array does not need to move. Payload regenerates its own sizes from the original file according to the upload config on the collection, so migrate the original and let Payload rebuild the derivatives. Keep the old attachment ID on each media document, because featured images and in-content image blocks both reference attachments by that ID and resolve against it later.
Users, authors, and comments
Authors carry across as a relationship. WordPress holds accounts in wp_users with roles serialized into a wp_capabilities row in wp_usermeta. Payload’s users collection is auth-enabled and stores its own credentials, so the profile data moves and the passwords stay behind. WordPress hashes are its own format, and every migrated account starts with a password reset.
Comments come with a choice attached. wp_comments and wp_commentmeta hold threading through a comment_parent self-reference, along with the moderation state and the author details for people who never had accounts. Model them as a Payload collection and carry the thread, move them to a dedicated commenting service and keep the archive as static output, or publish without them. All three are defensible. What decides it is whether comments still earn their moderation cost on the new site.
Permalinks and redirects
A WordPress permalink is generated from a structure string, and the estate’s structure decides how much redirect work the migration carries. The tags in play are %year%, %monthnum%, %day%, %hour%, %minute%, %second%, %post_id%, %postname%, %category%, and %author%, assembled through six settings: Plain, Day and name, Month and name, Numeric, Post name, and a custom structure.
The shape of the old URL set follows from that choice:
| Setting | Old URL |
|---|---|
| Plain | /?p=423 |
| Day and name | /2019/03/14/my-post/ |
| Month and name | /2019/03/my-post/ |
| Numeric | /archives/423 |
| Post name | /my-post/ |
Two of these need more than a pattern rewrite. Plain permalinks address posts by ID, so forwarding them requires the legacy ID map the import already built. Structures using %category% embed a taxonomy term in the path, and where a post sat in several categories WordPress picked one alphabetically, so the live URL set is the only reliable source. Category and tag archives carry their own prefixes, category and tag by default.
Export the live URL set before cutover and produce a redirect for every path whose structure changes. Serve 301 for content that moved and 410 for content deliberately dropped, which keeps intentional removals out of the soft-404 report. Payload’s redirects plugin stores the map as documents in a collection. That matters at WordPress volume: a decade of posts produces a list nobody will hand-maintain in a file.
Where the budget goes
The rich-text transformer is the long pole on any estate with custom blocks or a mixed classic and Gutenberg history. The core-block walker is a known quantity; each plugin block is its own small project, and the count of them is the number to establish before anyone quotes the work.
The field model costs whatever ACF was allowed to become. A dozen field groups with flat text and image fields map in a sitting. Nested Flexible Content inside Repeaters, cloned across groups, is where the mapping table stops being mechanical.
Redirects cost in proportion to the permalink structure and the age of the estate. A site on Post name permalinks that keeps its slugs needs almost nothing. A fifteen-year archive on Day and name with category-scoped URLs is a verification job in its own right, and it is the one that decides whether the traffic survives the move.
The way to price a specific estate is a scoped read of the block inventory, the field groups, and the live URL set. A 25-minute call is the fastest way to start one.
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.