Migrating Off a Platform That's Fighting You: WordPress to Astro
The migration isn’t finished when the new site works. It’s finished when you delete the old one.
There’s a specific moment when a platform stops being a tool and starts being an opponent. You want to change how a page is laid out, and instead of changing it, you’re negotiating with a page builder. You want to move a section, and you’re fighting a drag target that snaps back. Meanwhile the droplet running all of it bills you every month for the privilege.
That was the state of RC Journey, a publishing site with 22 articles on WordPress and Elementor. It worked. It was also expensive to keep and miserable to change, which is a combination that quietly gets worse forever, because every month you don’t deal with it is a month you pay for it.
I rebuilt it as a static Astro site and turned the old server off. Here’s what the migration actually consisted of, including the parts that were more work than the tutorials suggest.
Export is the easy part
wordpress-export-to-markdown does what it says. Point it at a WordPress export XML and it produces a directory of markdown files, one folder per post, with each post’s images co-located alongside it:
output/posts/<slug>/
index.md
images/
Ten minutes of work, and it feels like you’re nearly done. You’re not. What you have at this point is markdown whose text is correct and whose references are all still pointing at a server you’re about to destroy.
The real migration is everything between that export and a site that no longer needs the original to exist.
The decision that protected five years of URLs
The first real decision was where the migrated content should live, and the obvious answer turned out to be the wrong one.
The obvious answer is to move it: take the export output and reorganize it into src/content/posts/ the way a greenfield Astro site would. Tidy. Idiomatic. It also means every URL is now something you’re deciding rather than something you’re preserving, and the moment you’re deciding URLs you owe yourself a redirect map covering every article the internet already knows about.
So I didn’t move it. The content collection loads the export output in place, and the folder name becomes the entry id:
const posts = defineCollection({
loader: glob({
pattern: '*/index.md',
base: './output/posts',
// entry is "<slug>/index.md" -> id "<slug>"
generateId: ({ entry }) => entry.replace(/\/index\.md$/, ''),
}),
// ...
});
The slug WordPress used is the folder name, the folder name is the entry id, and the entry id is the URL. Every article kept the address it already had, not because I maintained a mapping but because there was never a point in the pipeline where the address could change. Category routes got the same treatment, matching the original page URLs rather than inventing new ones.
That’s the difference between preserving SEO and re-establishing it. A redirect map is a thing you maintain, and things you maintain drift. A URL that structurally cannot change is not a thing you maintain at all.
1.7 gigabytes of someone else’s filenames
Here’s the part that ate the most time, and the part nobody’s migration tutorial prepares you for.
The exported markdown was full of image references, and those references pointed at absolute URLs on the old infrastructure. Not one host, either. Over the site’s life the images had been served from an old droplet’s bare IP address, from a Google Cloud box, and from the domain itself. Same images, three different origins, scattered across 22 articles depending on what year the post was written.
Every one of those references was about to become a broken image, because I was going to delete the machine at the other end.
The backstop was an rsync of the entire /wp-content/uploads tree: 1.7 GB, gitignored, never shipped. The fix was a script (scripts/rewire-images.mjs) that resolves each reference against that local copy, and the regex is the tell:
// Any URL whose path contains /wp-content/uploads/<rest>. Captures <rest>.
const UPLOAD_URL = /https?:\/\/[^\s)"']+?\/wp-content\/uploads\/([^\s)"']+)/g;
It deliberately doesn’t care what the host is. Matching on the path rather than the origin is what makes one pass handle all three eras of hosting at once.
Two details made it survivable rather than maddening:
It indexes the uploads tree by relative path and by bare filename. WordPress reorganizes uploads over the years, so the path recorded in a 2021 post isn’t always the path the file sits at now. Falling back to basename catches those.
It reports what it couldn’t find instead of silently continuing. The run ends with a count of unresolved references, and if that count isn’t zero, you have a list of exactly which images need hunting rather than a site that looks fine until someone scrolls.
It’s idempotent, and it takes --dry. Both of those matter for the same reason: a script that rewrites 22 articles’ worth of content is a script you want to run in preview mode first and re-run without fear afterward.
Make the content model refuse bad content
Migrated content is untrustworthy content. It was authored over years, in a different system, by rules that were enforced loosely if at all. Some of it has fields the new site needs and some of it doesn’t, and you will not find that out by reading 22 files.
So the Zod schema is deliberately strict, and a violation fails the build:
schema: ({ image }) =>
z.object({
title: z.string(),
author: z.string(),
date: z.coerce.date(),
// Unknown/typo'd category -> build error.
categories: z.array(z.enum(CATEGORY_KEYS)).min(1),
tags: z.array(z.string()).default([]),
// Resolves ./images/<file>; missing file -> build error.
coverImage: image(),
draft: z.boolean().default(false),
}),
The two load-bearing lines are the category enum and coverImage: image(). A typo’d category doesn’t render an empty section page six months from now, it stops the build today. A cover image that didn’t survive the export doesn’t become a broken hero, it stops the build today.
This is the same instinct as a guardrail in CI, applied to content instead of code: make the failure loud, early, and impossible to merge past. The alternative is discovering it in production, which for a migration means discovering it after you’ve deleted the only other copy of the site.
The service worker that served yesterday’s site
One genuinely nasty bug, in the spirit of the deploy that looked green and shipped nothing.
I added a PWA integration, which by default precaches your built assets and serves them from a service worker. Fast, offline-capable, and for a multi-page content site, subtly wrong out of the box. If HTML gets precached, a returning visitor can be served a cached page from the previous deploy that references an asset filename the new build already replaced. The page loads. It’s just stale, and occasionally broken in a way that clears itself if you happen to hard-refresh.
The fix is to precache only the things that are safe to cache forever, and to let pages come from the network first:
workbox: {
// Precache only immutable, content-hashed assets — NOT HTML.
globPatterns: ['**/*.{css,js,woff,woff2,ico}'],
navigateFallback: undefined,
runtimeCaching: [
{
urlPattern: ({ request }) => request.mode === 'navigate',
handler: 'NetworkFirst',
options: { cacheName: 'pages', networkTimeoutSeconds: 3 },
},
// ...
],
}
Astro content-hashes CSS, JS, and font filenames, so those can be cached aggressively and safely: a new build produces new filenames. HTML has no such guarantee, so navigations go to the network first and fall back to cache only when the network is actually unavailable.
That navigateFallback: undefined line looks like a no-op and isn’t. The integration checks whether the key is present, not whether it’s truthy, so omitting it entirely leaves the default in place while explicitly setting it to undefined overrides it. Delete that line and you get a fallback handler bound to a URL that is no longer precached, which breaks the worker. It’s the sort of thing you find by reading the integration’s source after your service worker starts misbehaving.
The last step is destroying the server
Everything up to here is reversible. The old site is still running, the new one is a preview URL, and if the new one is wrong you shrug and keep paying the bill another month.
The actual migration is the cutover, and it goes in this order for a reason: point DNS at the new site (apex and www, both over HTTPS), verify the live result against the old site rather than against your local build, and only then destroy the droplet.
That last step is the one people postpone, and postponing it is how you end up paying for two systems while telling yourself you’ve migrated. The bill doesn’t stop when the new site works. It stops when the old server is gone.
It’s also the only irreversible step, and worth respecting as one. That 1.7 GB uploads backstop can never be re-rsync’d now, because the machine it came from doesn’t exist. Every image the site needs is committed to the repository, which is precisely why deleting the source was safe. If they’d still been referenced remotely, destroying that droplet would have taken 22 articles’ worth of images with it.
Deploy is GitOps from there: push to main, DigitalOcean App Platform builds and ships. Which, as I’ve written about before, has its own opinions about your package-lock.json. This project hit that exact bug too, and the fix was the same one: build under the same Node version the platform builds under, and stop treating that as something you remember rather than something the repo enforces.
What actually transfers
The specifics here are WordPress-shaped, but the shape isn’t.
Preserve addresses structurally, not manually. If your migration has a step where a human decides what a URL becomes, that step will eventually be done wrong. Wire it so the old address survives by construction.
Assume every reference points somewhere that’s about to die. Content authored against live infrastructure is full of dependencies on that infrastructure. Find them with a script that reports what it couldn’t resolve, because the ones it can’t resolve are the entire risk.
Make the new system reject the old system’s bad habits at build time. Migrated content has accumulated years of inconsistency. A strict schema turns that from a slow discovery process into a single loud failure you fix once.
Finish it. A migration that leaves the old system running isn’t a migration, it’s a second system. The moment the old thing is provably unnecessary, delete it.
The page builder isn’t fighting anyone anymore. Neither is the monthly invoice. The site is 22 markdown files, a build command, and a repository that anyone can read, which is what it should have been the entire time.
The droplet is gone. That’s how I know it worked.