Now booking enterprise content platform builds for 2026. Contact us

CMS migrations WordPress

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.

  • From WordPress
  • To Payload
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 typePayload fieldNotes
TexttextDirect
Text AreatextareaDirect
NumbernumberDirect
RangenumberCarry the min/max as validation
EmailemailDirect
URLtextPayload has no dedicated URL type; validate on write
PasswordLeave behind
SelectselectACF choices become options
Checkboxselect, hasMany: trueACF’s checkbox is multi-value
Radio ButtonradioDirect
Button GroupradioSame data, different admin control
True / FalsecheckboxDirect
ImageuploadMigrate media first
FileuploadSame
Galleryupload, hasMany: trueThe sequence is editor-set; preserve it
Wysiwyg EditorrichTextHTML in, Lexical out; see below
oEmbedtextStore the URL; the embed renders at the front end
Date PickerdateDirect
Date Time PickerdateDirect
Time PickerdateDirect
Color PickertextHex string
Google Mappoint plus groupCoordinates to point, address parts to a group
Icon Pickertext or selectDepends on whether the set is fixed
RepeaterarrayRow for row
Flexible ContentblocksEach layout becomes a block
GroupgroupDirect
CloneInline the cloned fields at the target
AccordionPresentational; use collapsible
TabPresentational; use tabs
Post ObjectrelationshipSingle
Relationshiprelationship, hasMany: trueMultiple
TaxonomyrelationshipPoints at the collection the taxonomy became
UserrelationshipPoints at the users collection
LinkgroupThe Link Array return format gives url, title, target
Page Linktext or relationshipRelationship 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_keymeta_value
subtitleA working reference
_subtitlefield_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_keymeta_value
staff_members2
staff_members_0_nameAda
staff_members_0_thumbnail412
staff_members_1_nameGrace

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 blockLexical node
core/paragraphparagraph
core/headingheading, tag from the level attribute
core/list and core/list-itemlist and listitem
core/quotequote
core/pullquotequote, or a custom block to keep the styling
core/imageupload node pointing at the migrated media doc
core/galleryCustom block wrapping several uploads
core/embedCustom block holding the provider and URL
core/code and core/preformattedcode
core/separatorhorizontalrule
core/tableCustom block
core/columns and core/columnCustom block, or flatten
core/groupUnwrap, or a custom block where the grouping matters

The exceptions:

  • core/html and core/shortcode hold 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 blockName outside the core/ 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 keyWhat it holds
_wp_attached_fileThe file path relative to wp-content/uploads
_wp_attachment_metadataWidth, height, file, the generated sizes array, and image_meta
_wp_attachment_image_altThe 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.

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:

SettingOld 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.


Rather have it done for you? WAYF runs WordPress to Payload migrations end to end.

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.