Getting content out of WordPress takes minutes; getting it out clean is the real work. A tested pipeline: raw REST export, Gutenberg block parsing, shortcode mapping, frontmatter and MDX validation.

Getting content out of WordPress takes minutes. Getting it out clean, with headings, images, embeds, captions and SEO fields intact and in a form MDX will compile, is where most of a migration’s effort goes.
This guide is a repeatable pipeline to export WordPress to MDX: export raw content, parse Gutenberg blocks, convert shortcodes, write frontmatter, then validate every file before it ships. It is part of our WordPress to Next.js migration guide, and we ran each script below before publishing it.
| Source | What you get | Watch for |
|---|---|---|
| Tools → Export (WXR) | XML with posts, pages, comments, terms and custom fields; content stored raw | XML is awkward to transform; one big file |
| REST API, public | JSON with rendered HTML | Shortcodes already executed; drafts excluded |
| REST API, authenticated | JSON with raw content as well as rendered | Needs an Application Password |
| Direct database dump | Everything, including plugin tables | Serialised PHP to decode; easy to misread |
For most migrations the authenticated REST API is the best balance. With context=edit on an authenticated request, fields such as title, content and excerpt include a raw value as stored in the database alongside the rendered HTML. WordPress has shipped with Application Passwords since version 5.6, so you can create a credential for the export script from a user’s profile screen without installing anything.
Two REST features make the export lighter. The _fields parameter returns only what you ask for, and since WordPress 5.3 it supports nested properties, so you can request content.raw without content.rendered. And collections are paginated: per_page is capped at 100, and the X-WP-Total and X-WP-TotalPages headers tell you how many pages to fetch.
Before exporting, confirm the API can see everything you need. Custom post types and taxonomies must be registered with show_in_rest. Advanced Custom Fields data is hidden by default: field groups are not visible in the REST API until you opt in, one group at a time. If Yoast SEO is active, it appends yoast_head and yoast_head_json to standard REST responses, which gives you each post’s SEO title, description, canonical and robots settings without a second request.
Use an Administrator’s Application Password. The users endpoint only answers context=edit for accounts that can list users, so an Editor’s credential fails on the last request.
The media export matters twice over: source_url points at each original upload, and the link and post fields let you redirect attachment pages to their parent posts. If a plugin-added field such as yoast_head_json comes back empty in the edit context on your install, run a second pass for that field with context=view.
Block-editor content is HTML with comment delimiters such as <!-- wp:paragraph --> around each block. Do not strip those with regex. WordPress publishes its own parser on npm, @wordpress/block-serialization-default-parser, which converts a post’s HTML into a block-based structure, with each block’s name, attributes, inner blocks and saved HTML. Content outside any delimiter, including whole classic-editor posts, comes back as a block whose name is null, so one code path handles both editors. One catch: classic content is stored without paragraph tags, which WordPress adds at render time, so those blocks go through autop from @wordpress/autop first or their paragraphs merge into one.

The helpers below rebuild each block’s HTML, convert nested blocks the same way as top-level ones (so an embed inside a group still becomes a component), flag unfamiliar blocks, and convert the rest to Markdown with the unified toolchain. remark-mdx makes the output escape { and < so it also compiles as MDX:
Placeholders made only of letters and digits survive Markdown conversion untouched, which is why components go in at the very end. Print unhandledBlocks after a run: it is your to-do list of block types that need their own handler.
Expect trouble with pages built in a visual builder. They are common: the 2025 Web Almanac found around 60% of WordPress sites use a page builder, and their deeply nested, class-heavy markup converts into noisy Markdown at best. Identify those pages during the inventory and rebuild them as components or CMS-driven layouts instead.
Shortcodes survive in raw content as [tag attr="value"] or [tag]…[/tag]. Count them across the export first, decide what each becomes, then transform them before block parsing:
The regex is a pragmatic heuristic, not a full shortcode parser: it does not handle nested shortcodes of the same tag, which are rare. Its value is the report. unknownShortcodes tells you exactly which plugins’ output you still need to replace.
Frontmatter is where SEO fields and redirect data live once WordPress is gone. A practical schema:
Map seoTitle, seoDescription and noindex from yoast_head_json or your SEO plugin’s equivalent. Yoast’s title is the full title tag, usually with your separator and site name, so strip that suffix if your Next.js layout applies a title template, or the brand prints twice. Keep legacyId and legacyUrl on every file: they feed the ID and path redirects.
Write each post with gray-matter, using .mdx only when the post contains components:
Copy the complete wp-content/uploads directory from the server or a host backup rather than scraping it, which misses files nothing links to. WordPress stores several resized copies of each image with suffixes such as -300x200.jpg; point content at the originals in each media item’s source_url and let next/image generate responsive sizes. Since WordPress 5.3, very large uploads get a -scaled copy and source_url points at that; the true original is in media_details.original_image when present.
Keeping the /wp-content/uploads/ paths on the new site is the simplest way to avoid image redirects: copy the files into public/wp-content/uploads/, or, for a library of several gigabytes that would bloat every deploy, put them in object storage and add a rewrite for that path. While you are there, use alt_text from the export to find images with no alt text and fix them.

MDX is not forgiving Markdown. HTML comments are not supported (use JavaScript comments in braces instead), and an unescaped < or { has to be escaped, because MDX reads them as the start of JSX or an expression. Void tags need closing (<br />, not <br>), and when MDX renders with React an inline style must be an object, not a string. Legacy WordPress HTML breaks each of those somewhere, which is why ordinary posts are safer as .md: plain Markdown is not parsed as JSX.
Run three automated checks on every converted file:
Then run a parity check against the original export. For each post, compare word count, heading count, link count and image count between the source HTML and the converted file, and flag anything that drifts by more than a few per cent. Parity failures are how you catch a gallery that silently disappeared or a table that collapsed into a paragraph.
Not sure your content is worth converting as-is? In a free 15-minute audit we look at your site with you on a live screen-share and tell you what we would rebuild, what we would convert, and what we would drop.
Use deterministic tools for the bulk and keep AI for the leftovers: flagged blocks, unknown shortcodes and MDX compile failures. Models are good at proposing a component mapping for an odd plugin’s output, or rewriting a broken HTML fragment as valid JSX. They are also capable of quietly rewording a sentence, so every AI-touched file goes back through the parity check, and gets a human review if it is a high-traffic page.
When we moved ProEd’s blog from WordPress to Sanity we used the same pattern, scripted import first and hand review after, with Portable Text as the target instead of MDX.
A useful first step you can take today: count the shortcodes in your WXR export. The number tells you how much of this pipeline you will actually need.
Lunova Labs builds Next.js sites and moves WordPress content across, converted, validated and checked against the original. Tell us about your site.
Ready when you are