Migrate WordPress to Astro with the REST API, Not XML Export
20 Oct 2026 · 7 min read

Most guides to moving WordPress to Astro start with Tools → Export. You download an XML file, run a converter, and then find out the images are still on the old server and the captions have melted into the body text.
This site ran on WordPress until recently. I moved all 25 of its posts into Astro with one Node.js script, 84 lines long, that reads the WordPress REST API instead of the export file. It downloads every featured and inline image next to its post, keeps figure captions, and writes Markdown that Astro’s content collections load directly. No plugin, no export file, and WordPress could be switched off afterwards.
Here’s the whole approach, the parts that matter in the script, and what I’d change for a bigger blog.
The export file is the wrong input
The WXR file from Tools → Export holds your posts, pages, comments, custom fields, terms and users, according to WordPress’s own documentation. What it holds for each post is the content as stored: shortcodes unexpanded, block comments in place, paragraphs not yet wrapped. Images are references to attachments, not the files.
The REST API gives you something closer to what readers actually saw. A post’s content.rendered is the HTML after every the_content filter has run, so shortcodes such as [caption] have already become <figure> and <figcaption>. (The unprocessed version, content.raw, needs ?context=edit and an authenticated user, as XWP’s write-up on REST contexts explains.)
Rendered HTML is the better input, because it’s the post your readers saw, not the one the editor stored. Turning HTML into Markdown is a solved problem. Turning shortcodes and block comments into Markdown is a project.
The ranking guides do mention the API. BrowserCat’s migration guide calls it “often the easiest extraction path”, then leaves images as “download the images, decide which sizes to keep, and update all references”. Astro’s own WordPress guide uses the same API, but to keep WordPress as a headless CMS, not to leave it. The script below does the part they skip.
One request, with everything embedded
const API = 'https://danielbroadhurst.co.uk/wp-json/wp/v2/posts?per_page=100&_embed';
const posts = await (await fetch(API)).json();
_embed is doing the heavy lifting. It tells WordPress to include linked resources under an _embedded key (the REST handbook), so each post arrives with its featured media and its terms. No second request per post for the cover image, no lookup table for category IDs:
const media = post._embedded['wp:featuredmedia']?.[0];
const cover = media ? await download(media.source_url, dir) : undefined;
const categories = post._embedded['wp:term'][0].map((t) => ({
name: decode(t.name),
slug: t.slug,
}));
wp:term[0] is categories; [1] would be tags. I kept category slugs exactly as WordPress had them, which made the old archive URLs easy to redirect later.
Images live next to the post, and srcset goes
Each post becomes a folder, src/content/posts/<slug>/, with index.md and its images beside it. The download helper names the file after the last path segment of its URL and returns a relative path:
const download = async (url, dir) => {
const name = decodeURIComponent(new URL(url).pathname.split('/').pop());
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} for ${url}`);
await writeFile(path.join(dir, name), Buffer.from(await res.arrayBuffer()));
return `./${name}`;
};
Inline images get the same treatment. Before converting, the script strips srcset and sizes, then rewrites every wp-content image URL to its downloaded copy:
let html = post.content.rendered.replace(/\s(srcset|sizes)="[^"]*"/g, '');
for (const [, url] of html.matchAll(
/<img[^>]+src="(https:\/\/danielbroadhurst\.co\.uk\/wp-content\/[^"]+)"/g,
))
html = html.replaceAll(url, await download(url, dir));
Dropping srcset isn’t tidiness. Every candidate in it points back at /wp-content/uploads/, a server that’s about to go away. Let Astro own responsive images: it can only optimise what’s in the repo. Local images referenced with a relative path from Markdown are processed at build time (Astro’s images guide), and with image: { layout: 'constrained' } in the config, Astro writes its own srcset and sizes for every one.
The throw on a failed download is deliberate. I’d rather the import stop on a 404 than produce a post with a hole in it.
The Turndown rule that keeps captions
Turndown converts the HTML to Markdown, with the GFM plugin for tables. It has no rule for <figcaption>, so it falls back to its default: output the element’s text as a plain block. The caption survives, but as an ordinary paragraph, indistinguishable from the text around it.
One custom rule fixes that:
turndown.addRule('figcaption', {
filter: 'figcaption',
replacement: (content) => `\n\n*${content.trim()}*\n\n`,
});
Added rules run before Turndown’s built-in ones, so this wins. A WordPress image block now comes out as:

*Manually trigger the first deployment using the Run build button.*
A caption that reads as body text is worse than no caption, because it silently changes what the paragraph says. It’s four lines of code and it’s the edge case I haven’t seen another guide handle, because a converter built around the WXR file sees [caption] shortcodes, not <figcaption>.
Wiring it into Astro’s glob() loader
The frontmatter is written with JSON.stringify for every value, because a JSON string is valid YAML and titles full of colons and quotes stop being a problem. Dates go through as WordPress gives them.
On the Astro side, one collection loads every folder:
const posts = defineCollection({
loader: glob({
pattern: '*/index.md',
base: './src/content/posts',
// The folder name is the post's URL slug.
generateId: ({ entry }) => entry.replace(/\/index\.md$/, ''),
}),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
excerpt: z.string(),
categories: z.array(z.object({ name: z.string(), slug: z.string() })),
cover: image().optional(),
coverAlt: z.string().optional(),
draft: z.boolean().default(false),
// …plus a few optional fields added since (updated, noindex, series)
}),
});
generateId matters here. By default the glob loader builds an ID by slugifying the file path (content loader reference), and every file in this layout is called index.md. Taking the folder name gives each post its WordPress slug back, so /posts/<slug>/ lines up with the old permalink’s last segment.
image() in the schema is what turns cover: "./cover.jpg" into an optimised, type-checked image, and makes the build fail if a cover is missing. One Zod schema is the migration’s test suite: if an imported post is malformed, astro check says so. Posts I’ve written since the move use exactly the same shape, so imported and new posts are indistinguishable to the site.
What needed fixing, and what I’d script differently
The script moves structure faithfully, and that includes faithfully moving old mistakes. The content needed a human pass afterwards, and a bigger blog would need a few changes to the script.
Alt text. The script copies each cover’s alt_text, falling back to the post title when it’s empty. What came across was whatever I’d typed into the media library years ago, such as “DJ Broadhurst | Website”: a label, not a description. The articles got new covers and real alt text afterwards. On a big site, I’d log every fallback and every alt that looks like a label, rather than fill them silently.
Resized originals. The src in rendered content is usually a WordPress intermediate size, so files arrived as image-7-1200x472.png, not the upload itself. Astro then resizes a resize. Next time I’d strip the -WIDTHxHEIGHT suffix and fetch the original, falling back to the sized one on a 404.
Old posts. Six of the older portfolio posts were thin enough that I kept them online but marked them noindex, which also drops them from the sitemap.
Old URLs. WordPress served posts at /<category>/<slug>/. A small worker in front of the static site 301s those to /posts/<slug>/, sends /feed/ to the RSS file, and returns 410 Gone for /wp-admin, /wp-json and the rest. Keeping WordPress’s slugs made that a single regex.
Pagination. per_page is capped at 100. I had 25 posts, so one request was enough. For more, read the X-WP-TotalPages header and loop &page=. The same goes for pages (/wp/v2/pages) if you need them.
Other hosts. The image regex only matches this site’s wp-content. If your posts pull images from a CDN or an old http:// domain, widen the pattern, or those images stay remote and break when the old host goes.
I moved a WordPress.com blog to self-hosted WordPress years ago, keeping every post and image, and that was a job for plugins. This time it was a script I could read end to end and re-run whenever I found a problem: re-running overwrites the imported posts and leaves anything written since alone.
If you’re migrating your own
- Read the REST API, not the export file.
content.renderedhas shortcodes and blocks already turned into HTML. - Use
_embed. Featured images and categories come back with each post in one request. - Download images next to each post and drop
srcset. Astro can only optimise images it can see, and it writes bettersrcsetthan the one pointing at your old server. - Add a Turndown rule for
figcaption. Otherwise captions become ordinary paragraphs. - Set
generateIdfrom the folder name. It keeps your WordPress slugs and your URLs. - Let the schema catch mistakes. A strict Zod schema with
image()fails the build on a missing cover or a bad date, before a reader finds it.


