How to Migrate WordPress to Astro Without Losing Your Search Traffic
Filed under: DevOps · SEO · WordPress Infrastructure
The rebuild is the easy part.
That is the thing nobody tells you about leaving WordPress. Porting the design, converting the content, wiring up the templates: a competent developer with an AI pair does that in days, and it is the part everyone writes blog posts about. Then the site goes live and traffic drops 40% because a wildcard redirect quietly ate 108 pages that were still earning clicks, and no amount of Lighthouse score gets it back.
Two of my sites came off WordPress this year. One was a marketing site with about 245 pages, a 213-entry glossary and a blog. The other was a Polish legal-document site with roughly 1,100 document pages and 2,821 URLs in the old crawl. Both now run Astro on a static host. Both kept their rankings through the move. Neither cutover was clean the first time I rehearsed it.
This is the playbook I wish I had before the first one. It assumes you are comfortable in a terminal and can read code, and it assumes you have an AI assistant doing the actual typing. I am a psychologist and a product person, not a developer, and every script in this post was written by Claude against a spec I wrote first. That detail matters less than it sounds, and I will come back to why.
Table of Contents
- Why I Left WordPress on Two Sites and Kept It on a Third
- What the Target Stack Looks Like
- Step 1: Inventory the Old Site Before You Change Anything
- Step 2: Read WordPress Through Its Own REST API
- Step 3: Convert Page-Builder HTML Into Clean Markdown
- Step 4: Preserve URLs Exactly
- Step 5: Decide What You Are Not Migrating
- Step 6: Build the Redirect Map
- Step 7: The Redirect Trap That Cost Me Two Live Pages
- Step 8: Turn URL Parity Into a Build Guard
- Step 9: Stop Your Preview From Becoming a Second Site
- Step 10: Find Out What the SEO Plugin Was Doing for You
- Step 11: Rehearse the Cutover, Then Flip DNS
- Step 12: The First Two Weeks
- Where Claude Actually Helped
- The Migration Checklist
Why I Left WordPress on Two Sites and Kept It on a Third
Earlier this year I wrote a seven-part series on self-hosting WordPress on a bare VPS: LEMP stack, four layers of caching, fail2ban, the whole thing. So there is an obvious question about consistency here, and the honest answer is that WordPress was never the problem. My own operational tolerance for it was.
A content site on WordPress accumulates a maintenance surface that has nothing to do with content. Plugin updates that can break a layout at 2am. A page builder whose output you cannot diff. A database whose state is the site, so every change is a live change. Caching layers that exist to undo the cost of rendering pages that never change between deploys. For a marketing site or a document library, all of that is machinery in service of a problem you do not have.
Static generation removes the category. The site becomes a git repository, the build is reproducible, a bad deploy is a revert, and there is no origin server to compromise because there is no origin server. That is the actual trade.
What I kept on WordPress is a WooCommerce store, because a store has genuine dynamic state and WooCommerce is genuinely good at it. The rule I settled on: if the page is the same for every visitor, it has no business being rendered per request.
If your site is mostly logged-in state, transactions, or heavy editorial workflow with several non-technical editors, stay where you are. This post will cost you a week and gain you very little.
What the Target Stack Looks Like
Both migrations landed on the same shape:
- Astro for the site. It ships zero JavaScript by default, its content collections give you typed Markdown with schema validation at build time, and the templating is close enough to HTML that porting a page-builder layout is transcription rather than translation.
- Tailwind for styles, because rebuilding a theme is faster than porting one.
- A static host with edge functions. I use Cloudflare Pages. Netlify or Vercel work the same way. What matters is that you get a
_redirectsfile, preview deployments on every branch, and the ability to run a tiny function at the edge for the two or three things a static build cannot do. - Git as the CMS. Content is Markdown in the repo. Editing is a commit. History is free.
The single most useful piece of configuration in the whole migration is two lines:
// astro.config.mjs
export default defineConfig({
site: 'https://www.example.com',
trailingSlash: 'always',
build: { format: 'directory' },
});
WordPress serves /my-page/ with a trailing slash. Astro defaults to a shape that does not. Get this wrong and every single URL on your site changes, every redirect becomes a double hop, and you have manufactured a migration problem out of a default value.
Step 1: Inventory the Old Site Before You Change Anything
Do this first, while the old site is still live and intact. You are producing three artifacts.
A complete URL list. Take it from the old sitemap index. WordPress SEO plugins generate sitemap_index.xml with child sitemaps for pages, posts, categories, authors and taxonomies. Pull every <loc> from every child. On the legal site this produced 2,821 URLs, which was roughly 2.3 times what I would have guessed by clicking around.
Click data per URL. Export the last 6 to 16 months from Search Console, at page level, with clicks and impressions. This is the file that decides what you migrate. Without it you are guessing, and I want to be precise about how badly guessing goes: on one site a page I had recorded in a planning doc as “20 clicks / 643 impressions” was actually 285 clicks and 2,579 impressions over the same window, and it was the sixth most valuable page on the property. The figure had been written down once, early, and then trusted for months.
Your backlink profile at page level. Whatever tooling you use, get a list of which old URLs actually have inbound links from other domains. A URL with backlinks earns a redirect even if it has no traffic, because the link equity is real and transferable. A URL with neither traffic nor links has earned nothing.
Commit all three to the repository. They are the inputs to the parity guard in Step 8, and you want them under version control before the old site starts changing under you.
Step 2: Read WordPress Through Its Own REST API
You will find a lot of advice about database dumps and export plugins. Skip both. Every WordPress install exposes a read-only JSON API at /wp-json/wp/v2/, and it gives you exactly what you need without touching the production site.
async function fetchAll(type) {
const out = [];
for (let page = 1; ; page++) {
const url = `${ORIGIN}/wp-json/wp/v2/${type}` +
`?per_page=100&page=${page}` +
`&_fields=id,slug,link,parent,title,content,modified,date`;
const res = await fetch(url);
if (res.status === 400) break; // past the last page
if (!res.ok) throw new Error(`${type} page ${page}: ${res.status}`);
const batch = await res.json();
if (!batch.length) break;
out.push(...batch);
}
return out;
}
const pages = await fetchAll('pages');
const posts = await fetchAll('posts');
Three details that matter. per_page=100 is the API maximum. Past the last page WordPress returns a 400 rather than an empty array, which is the loop’s exit condition. And _fields is not an optimization: without it you pull every rendered field including full HTML for excerpts and yoast blobs, and a 1,000-page site becomes a very slow, very large download.
The parent field is how you recover hierarchy. On the marketing site the glossary was 213 child pages under one parent, so identifying them was a walk up the parent chain to a single known ID rather than a slug pattern match.
Write two outputs from this script, not one:
- Raw HTML per page, dumped to a
wp-export/directory that you gitignore. This is your porting reference. When you rebuild a landing page by hand you want the original markup open next to you, and you do not want it in the repo forever. - Markdown with frontmatter for anything that is genuinely content: blog posts, glossary entries, documentation. These become Astro content collections.
Landing pages get rebuilt by hand. There is no converter that turns a page-builder hero section into a good component, and thirteen hand-built pages took less time than fighting an automated port would have.
Step 3: Convert Page-Builder HTML Into Clean Markdown
Turndown handles the HTML to Markdown conversion. The work is in the rules you add before it runs, because page-builder output is 80% structural noise.
const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
// Structural elements that carry no content
td.remove(['script', 'style', 'nav', 'noscript', 'svg', 'button']);
// Decorative elements the builder marked as such
td.addRule('ariaHidden', {
filter: (node) => node.getAttribute?.('aria-hidden') === 'true',
replacement: () => '',
});
// Unwrap the builder's own layout divs, keep their contents
td.addRule('builderBlock', {
filter: (node) =>
node.nodeName === 'DIV' &&
node.className?.split(' ').includes('lc-block'),
replacement: (content) => content,
});
Then four post-processing passes that I did not anticipate needing and now apply by default:
Strip the leading H1. WordPress stores the page title separately from the body, and most themes render it from the title field. But page builders often put an H1 in the body as well. Your frontmatter title will render an H1 in the Astro layout, so if you keep the body H1 you ship every migrated page with two H1s. On the marketing site this was 216 pages with a duplicate heading, caught only because a build-time check counted them.
Strip the related-content footer. Page builders append “Related articles” or “Other glossary items” blocks into the body HTML. In WordPress that block was generated. In Markdown it becomes a frozen list of links that rots the moment you rename anything. Find the marker text, walk back to the enclosing section, and cut from there.
Rewrite absolute internal links to relative. Every internal link in the export points at https://www.oldsite.com/path/. Left alone, your new site links to the old domain, which after cutover is either a redirect chain or nothing at all.
Extract a real meta description. If the old SEO plugin fields did not come through cleanly, derive one from the first genuine paragraph, decoded and truncated on a word boundary around 155 characters. It beats an empty description on 200 pages.
Write these as pure functions in their own module and have Claude write tests for each one against fixtures pulled from the real export. I ended up with 26 tests on the marketing site’s migration library. That sounds like over-engineering for a script you run once. It is not, because you will run it more than once: every time you fix a conversion rule you re-run the whole export, and without tests you cannot tell whether fixing the H1 rule broke the link rewriter.
Step 4: Preserve URLs Exactly
The default position on every URL is that it does not change. Not the slug, not the depth, not the trailing slash, not the language of the path segments.
This is unglamorous and it is the single highest-leverage decision in the migration. Every URL you preserve is a redirect you do not write, a redirect chain that does not exist, and a ranking signal that transfers without loss. Every URL you “improve” is a bet that your new structure is worth more than the accumulated authority of the old one, and you will almost always lose that bet.
If you genuinely must restructure, do it as a second project, three months after the platform is stable, so that when traffic moves you know which change caused it.
Step 5: Decide What You Are Not Migrating
Not every URL deserves to exist. A migration is the only moment when pruning is nearly free, so use it.
On the legal site, 1,651 of the 2,821 old URLs were location permutations and a defunct news section, together carrying 3.5% of traffic. Those were dropped deliberately. Every section that earned meaningful traffic moved across one to one.
Two rules govern this, and the second one is where I got it wrong.
Rule one: a page with no clicks and no backlinks has earned nothing. Migrating it costs build time, dilutes your internal link graph, and often creates cannibalisation you then have to fix.
Rule two: do not mass-redirect dead pages to a category hub. This is the reflex move and it is a mistake. A 301 from a specific page to a generic hub is a textbook soft 404. Google recognises it, passes no equity through it, and deindexes the URL more slowly than a clean 410 or 404 would. A dropped page should return an error. That is what an error code is for.
Here is where I got it wrong. On the legal site I dropped all 371 court pages in one pass, reasoning that 252 of them had zero clicks, zero backlinks, and shared one title template across seven variants. That was true of 252 of them. The other 108 had earned 199 clicks in six months, and after cutover they were 301ing to a hub, decaying. Restoring them meant rebuilding that section from an open government dataset, which took a day. Reading the click data at slug level instead of at section level would have taken an hour.
Prune by URL, never by section.
Step 6: Build the Redirect Map
Four categories, in this order.
Old sitemap paths. Crawlers have sitemap_index.xml in their memory and will keep requesting it for months. Point every old sitemap path at the new one.
/sitemap_index.xml /sitemap-index.xml 301
/post-sitemap.xml /sitemap-index.xml 301
/page-sitemap.xml /sitemap-index.xml 301
/category-sitemap.xml /sitemap-index.xml 301
/author-sitemap.xml /sitemap-index.xml 301
WordPress structural paths you are not rebuilding. Author archives, feeds, tag archives, comment feeds. These exist on every WordPress install, they are indexed, and your Astro site has no equivalent.
/author/* /about/ 301
/feed/ /blog/ 301
/comments/feed/ /blog/ 301
/tag/* /blog/ 301
Legacy slugs that carry backlinks. This is the highest-value category and the one most people skip. Go through your backlink list, find every old URL with inbound links that has no 1:1 destination, and map it to the closest real page. Verify each target returns a 200 before you ship it. On the marketing site nine of these recovered link equity that would otherwise have died at a 404.
Consolidations. Pages you merged into a canonical version. Keep these as exact paths, never wildcards, for the reason in the next section.
One thing that does not belong in this file: apex to www. Host-level redirects are the DNS provider’s job, and mixing the two systems is how you build a loop.
Step 7: The Redirect Trap That Cost Me Two Live Pages
Read this sentence from the Cloudflare Pages documentation carefully, because it is the least intuitive rule in the entire stack:
Redirects are always followed, regardless of whether an asset matches.
On a normal web server, a real file wins over a redirect rule. Here, the redirect wins. Every time. Three separate incidents came out of this, and all three were live before I understood the rule.
A wildcard that eats its own parent. The obvious rule /section/* → /section/ 301 looks harmless. It is an infinite loop, because /section/ itself matches /section/*. The fix is to serve the parent explicitly first, since first match wins:
/section/ /section/index.html 200
/section/* /section/ 301
A stale redirect shadowing a real page. During the rebuild I had written /contact/ → /services/#contact because the Astro site had no contact page yet. Weeks later the contact page shipped. It worked perfectly in local dev and in preview builds. In production it 301ed away, because production reads _redirects and dev does not. The page was invisible for days.
A wildcard killing pages you deliberately restored. When I brought back those 108 court pages, the /section/* wildcard from the earlier pruning was still in the file. The pages built fine. The assets existed. Every one of them 301ed to the hub anyway. Removing the wildcard meant the remaining 263 dropped URLs now return a clean 404, which is what they should have been doing all along.
The operating rule I now follow: every wildcard in a redirects file is a standing liability. Use exact paths wherever you can enumerate them. When you must use a wildcard, write a comment above it saying what it is for and what would break if a page under that prefix ever came back.
Step 8: Turn URL Parity Into a Build Guard
Checking URL parity by hand once, before launch, is worth something. Checking it automatically on every build is worth vastly more, because the failure mode is not “we forgot to check at launch”. The failure mode is that four months later someone deletes a page and nothing notices.
The guard is about 40 lines. For every URL in your committed old-site inventory, assert one of two things is true: either the build produced an index.html at that path, or _redirects contains an exact 301 for it.
export function missingFromInventory(inventory, exists, redirected) {
return inventory.filter(
(route) => !exists(routeToFile(route)) && !redirected.has(route)
);
}
Two design decisions in there are worth stealing.
Accepting a 301 as a valid outcome is what lets you deliberately merge or drop a page without disabling the guard. My first version required a built file for every old URL, which meant the first legitimate merge broke the build and the pressure was to add an exclusion list. An exclusion list is a place where deleted pages go to become invisible.
Splat sources do not count. When parsing _redirects to build the “is it redirected” set, ignore any rule containing a wildcard. /section/* tells you nothing about whether /section/specific-page/ resolves, and counting it would let the guard pass while the page is gone.
This is strictly stricter than an exclusion list: a page removed with no redirect fails the build. Mine currently reports 7 merged away, each covered by a 301, and that line is the whole point. The merges are visible, counted, and deliberate.
Step 9: Stop Your Preview From Becoming a Second Site
Static hosts give every branch a preview URL. That is the single best thing about them and it is also how you end up with a complete, crawlable, 200-returning duplicate of your site on a hostname you do not own the brand for.
Two defences are commonly recommended and neither of them removes a duplicate.
A Disallow: / in the preview’s robots.txt stops crawling. It does not stop indexing by reference, and it does not stop a human sharing the link. A canonical tag pointing at production is a hint, not a directive, and Google is free to ignore it.
The fix is a 301 at the edge. But the implementation detail here is genuinely important, and I nearly shipped it wrong:
// Match the EXACT production alias. Never a suffix match on .pages.dev.
const CANONICAL_PREVIEW = 'yoursite.pages.dev';
Preview hosts come in three shapes: yoursite.pages.dev, branch.yoursite.pages.dev, and hash.yoursite.pages.dev. A suffix match takes all three. The first is the duplicate you want to kill. The other two are how you review a branch before merging, and how you rehearse the entire cutover. Kill those and you have removed your own ability to test.
While you are at the edge, replace the static robots.txt with a function that decides by hostname:
const PROD_HOSTS = new Set(['www.example.com', 'example.com']);
export function onRequest(context) {
const host = (context.request.headers.get('host') || '').toLowerCase();
const body = PROD_HOSTS.has(host)
? 'User-agent: *\nAllow: /\n\nSitemap: https://www.example.com/sitemap-index.xml\n'
: 'User-agent: *\nDisallow: /\n';
return new Response(body, {
headers: { 'content-type': 'text/plain; charset=utf-8' },
});
}
A static robots.txt creates a timing dependency at cutover: it has to say Disallow right up until the moment it must say Allow, and that flip is a deploy you have to remember to make while you are already doing five other things. A host-aware function has no cutover step at all. The correct answer follows the domain automatically.
Step 10: Find Out What the SEO Plugin Was Doing for You
Your SEO plugin has been quietly running background jobs for years. When it goes, they go, and nothing tells you.
Sitemap coverage of pages the build does not generate. Astro’s sitemap integration enumerates the routes it built. Anything served another way is invisible to it. On the legal site the highest-intent URL on the property, an application served through an edge proxy, was absent from the sitemap for weeks after cutover because Astro never built a page for it. Add it explicitly:
sitemap({
customPages: ['https://www.example.com/app-route/'],
})
Note the trailing slash. With trailingSlash: 'always', listing the bare form advertises a URL that redirects, and handing a crawler a redirect in your own sitemap is a self-inflicted wound.
Index-now pings. Most WordPress SEO plugins submit changed URLs to Bing on every publish. My rebuild shipped without an equivalent and nobody noticed for a day, during which nothing on the site was announced to Bing at all. Google is unaffected, since it does not use the protocol and the sitemap covers it. Bing simply stops hearing from you.
If you add this back, submit only the URLs that actually changed. The sitemap’s lastmod on a static build is the build timestamp, so submitting from the sitemap marks all 1,344 URLs as fresh on every deploy, which is the fastest way to get your submissions ignored. Derive the changed routes from the changed content files in the commit, and skip files that no longer exist: a merged-away page is deleted in the same commit that adds its 301, and submitting it asks the crawler to come look at a redirect.
There is a timing subtlety worth knowing about. Your CI runs on push; your host builds after the push. Submit immediately and you announce pages that are not live yet. The sitemap’s build-time lastmod doubles as a free deploy sentinel: poll it until it is newer than the run started, then submit.
Analytics continuity. Keep the same measurement ID the WordPress site was using. A new property restarts your history at zero and you lose the year-over-year comparison exactly when you most need it to prove the migration did not hurt. Overlap is fine: run both for a few days and stamp the preview traffic as internal.
Step 11: Rehearse the Cutover, Then Flip DNS
The preview deployment is a full production build on a different hostname. That means the cutover is rehearsable, and there is no reason to improvise it.
Write the runbook as an ordered list with a verification command under each step and a rollback under each step. Then walk it against the preview and mark each line. My first rehearsal on the legal site turned up four prerequisites that a paper review had recorded as done: an origin hostname still pointing at the WordPress server, an unset shared secret, a missing allowed origin, and an application branch that had never actually been deployed.
The most useful thing that rehearsal caught was a certificate path. Both edge server configs pointed at a certificate file that had stopped existing when the shared edge was reorganised two weeks earlier. Validating those configs in a throwaway container against the real mounts, without touching the running server, took ten minutes. That defect would have failed the cutover at step three, at the exact moment when the old site was already out of DNS.
Verify against reality, not against your notes. A readiness checklist that has never been executed is a wish list.
Then the flip itself. Lower your DNS TTL to 300 seconds a day ahead. Keep the WordPress server running, off the domain, as the rollback. Change the DNS record. Watch.
Step 12: The First Two Weeks
Reconcile the old crawl against the new sitemap. This is a five-minute set difference and it is the highest-yield thing you can do post-cutover. Old URLs minus new URLs minus redirected URLs equals your gap list. On the legal site that exercise surfaced both the missing sitemap entry and the live preview duplicate, neither of which any pre-launch check had caught.
Submit the new sitemap in Search Console and watch the coverage report, not the ranking report. You are looking for the crawl to discover the new structure. Expect two to four weeks of noisy positions.
Watch 404s in your host’s analytics. Real 404s from real referrers are the redirects you missed. Bot 404s on /wp-admin/ and /xmlrpc.php are the internet noticing you left, and they will continue for years.
Do not touch anything else for a month. If you redesign, restructure and re-platform simultaneously, and traffic moves, you will never know why. Ship the platform change alone.
Where Claude Actually Helped
The honest accounting, since this was AI-built end to end.
Where it was decisively better than me working alone: writing the migration library test-first, so that every conversion rule was a pure function with fixtures. Writing the parity guard, the coverage checks and the wildcard-parsing logic, which are exactly the kind of tedious, high-value guard code that humans skip because it feels like overhead on a one-off script. Reading the host’s documentation and surfacing the “redirects are always followed” rule, which I would have discovered the hard way. Converting page-builder HTML noise into rules rather than into hand-editing 213 files.
Where it needed supervision: anything involving state outside the repository. The deploy documentation carried the wrong Node version for weeks. A planning document recorded a page’s traffic at one fourteenth of its real value, and that number sat there being trusted because it had been written down confidently. The missing Bing submissions and the missing sitemap entry were both cases of an absence, and absences are what an assistant working from your repository cannot see. Nothing in the codebase says “the thing you deleted used to do something.”
The general shape: Claude is very strong at the things that are checkable from inside the repository, and structurally blind to the things that are only true outside it. So the discipline that worked was to convert every outside-world fact into an inside-the-repository artifact as early as possible. Commit the URL inventory. Commit the click data. Commit the redirect map. Then write guards that fail the build when the code and those artifacts disagree.
That is also why the parity guard matters more than any individual migration script. The script runs once. The guard runs forever, and it is what keeps a migration that succeeded in August from quietly unravelling in November.
The Migration Checklist
Before you start:
- Full URL list extracted from the old sitemap index, committed to the repo
- Page-level click and impression data for 6 to 16 months, committed
- Page-level backlink data, committed
- Decision recorded, per URL, on migrate / merge / drop
During the rebuild:
-
trailingSlashand build format match the old site’s URL shape exactly - Content exported through the REST API with
_fields, raw HTML kept as a gitignored reference - Conversion rules written as tested pure functions
- Duplicate H1s checked at build time, one per page
- Internal links rewritten from absolute to relative
- Redirect map covers old sitemaps, author / feed / tag paths, backlinked legacy slugs, and merges
- Every wildcard redirect carries a comment explaining what it would break
- Parity guard in CI: every old URL is built or exactly 301ed
- Host-aware robots.txt as a function, not a static file
- Preview alias 301ed to production with an exact-match rule that spares branch previews
Before you flip DNS:
- Runbook rehearsed end to end against a preview deployment
- Every prerequisite verified against the live infrastructure rather than against notes
- Server configs validated in a throwaway container
- Analytics measurement ID carried over from the old site
- Index-submission pings reimplemented, scoped to changed URLs, gated on deploy completion
- Pages served outside the build added to the sitemap via
customPages - DNS TTL lowered 24 hours ahead
- Old server still running, off the domain, as rollback
After:
- Old crawl reconciled against the new sitemap, gaps closed
- New sitemap submitted, coverage report watched
- Real-referrer 404s reviewed weekly
- Nothing else changed for a month
The rebuild will take you a week. The list above is the other three weeks, and it is the part that decides whether the migration is a platform upgrade or an expensive way to lose your rankings.
Related reading
WordPress Monitoring: Healthchecks and Telegram Alerts
Three-layer WordPress server monitoring: a self-healing cron healthcheck that auto-restarts services, a diagnostic status dashboard, and an external Telegram.
WordPress Backup Automation: Cron Jobs and Cache Warming
Automated WordPress server maintenance with cron: nightly cloud backups, MariaDB database optimization, 5-phase cache warming, plugin distribution across.
WordPress Security on VPS: Nginx, Fail2ban, SSL
Two-layer WordPress VPS security: Nginx blocks wp-login brute force and scanner probes before PHP runs, fail2ban bans repeat offenders at the kernel level.
WordPress on Hetzner VPS: Why I Left Managed Hosting
I moved my WordPress and WooCommerce sites from managed hosting to a Hetzner VPS. Walkthrough of provisioning, SSH hardening, and firewall on Debian 13.