
Migrating a small WordPress site to Astro is mostly a development task.
Migrating 40,000 pages is something else entirely.
It becomes a content, URL, SEO, media, routing, and deployment migration—all at once.
Our first plan looked simple:
Export WordPress
↓
Convert content
↓
Build with Astro
↓
Deploy
The real process looked more like this:
Inventory
↓
Extract
↓
Normalize
↓
Transform
↓
Generate
↓
Validate
↓
Deploy
The Astro components were not the hardest part.
Preserving years of WordPress history was.
Note: This is a representative large-scale migration post-mortem, not a customer case study.
The First Mistake: Building Before Inventorying
We started by creating Astro components:
Header.astro
ArticleLayout.astro
Footer.astro
RelatedPosts.astro
The first migrated pages looked perfect.
Then we reached older content.
We found:
- legacy shortcodes,
- page-builder markup,
- inline CSS,
- broken HTML,
- old CDN URLs,
- embedded forms,
- custom blocks,
- absolute internal links,
- obsolete plugin output.
That changed the question.
It was no longer:
Can Astro render WordPress content?
It became:
What has 15 years of WordPress actually stored?
Start With Every Existing URL
The most useful artifact in the entire migration became a URL manifest.
For every public URL, we recorded something like:
{
"source": "/guides/performance/",
"target": "/guides/performance/",
"type": "page"
}
This gave us a clear contract:
Old URL exists
↓
New URL exists
↓
Same path OR redirect exists
↓
Correct status
↓
Correct canonical
Without this inventory, there was no reliable way to answer:
Did all 40,000 pages actually migrate?
Treat WordPress Export Like an ETL Pipeline
Forty thousand records are not something you should treat as one giant API response.
WordPress REST API collections are paginated, so extraction needs to happen in batches.
Conceptually:
let page = 1;
while (true) {
const response = await fetch(
`${baseUrl}/wp-json/wp/v2/pages?per_page=100&page=${page}`
);
const records = await response.json();
if (!records.length) break;
await saveBatch(records);
page++;
}
The important change was adding checkpoints.
Fetch
↓
Normalize
↓
Save
↓
Checkpoint
↓
Continue
If batch 287 failed, we restarted from batch 287.
Not from zero.
Do Not Couple Astro Directly to WordPress Data
Instead of passing raw WordPress API responses through the application, we created a normalized content model.
For example:
type MigratedPage = {
sourceId: number;
path: string;
title: string;
description?: string;
content: string;
canonical?: string;
publishedAt?: string;
};
That gave us:
WordPress
↓
Extraction
↓
Normalized Content
↓
Astro
Now WordPress-specific decisions stayed in the migration layer instead of leaking into every Astro component.
Not Everything Should Become Markdown
We originally considered converting all content to Markdown.
That worked for clean pages.
It failed on years of legacy content.
For example:
[pricing_table product="enterprise"]
or:
<div class="legacy-callout">
...
</div>
A blind HTML-to-Markdown conversion could silently remove functionality or structure.
So we used three options:
- convert clean content,
- transform known legacy patterns,
- preserve sanitized HTML when conversion would lose information.
The goal was not format purity.
The goal was content fidelity.
Shortcodes Need Explicit Replacement Rules
WordPress understood old shortcodes because plugins interpreted them.
Astro does not.
We therefore classified them as:
Static
Convert during migration.
Dynamic
Replace with a real Astro component or service.
Dead
Remove or rewrite intentionally.
Unknown shortcodes became migration failures.
That was better than discovering this in production:
[old_widget id="32"]
Preserve URLs Unless You Have a Good Reason Not To
A framework migration is already risky.
We did not want to combine it with a complete URL redesign.
If WordPress used:
/guides/astro-migration/
Astro normally kept:
/guides/astro-migration/
When a URL genuinely changed, the redirect was stored as migration data:
{
"from": "/old-guide/",
"to": "/guides/new-guide/",
"status": 301
}
We also standardized:
- trailing slashes,
- canonical URLs,
- internal links,
- sitemap URLs,
- redirect destinations.
Routing was not merely a frontend decision.
It was an SEO requirement.
Media Was More Than /uploads
Images existed in more places than expected:
- page HTML,
- featured images,
- metadata,
-
srcset, - custom fields,
- CSS,
- old CDN URLs.
So assets received their own manifest:
{
"source": "https://old.example.com/uploads/hero.jpg",
"destination": "/media/hero.jpg"
}
This allowed us to detect:
- missing files,
- old WordPress URLs,
- failed rewrites,
- broken image references.
At 40,000 pages, guessing whether every asset migrated was not acceptable.
40,000 Static Pages Change Build Economics
Astro can generate large numbers of static routes.
But static pages are not free to build.
Every generated page requires work.
At this size, scalable web development also means thinking about build times, routing strategy, caching, and deployment economics—not only frontend rendering.
So we had to ask:
Does every page need to be regenerated on every deployment?
The answer depends on the site.
Possible approaches include:
Fully static
or:
Static important routes
+
on-demand rendering where appropriate
The key lesson was simple:
Fast runtime performance does not automatically mean fast builds.
Build time became an architecture constraint.
SEO Metadata Is Content Too
Moving the visible article was not enough.
We also needed to preserve:
- titles,
- meta descriptions,
- canonical URLs,
- publication dates,
- authors,
- structured data,
- social metadata where needed.
A page could render perfectly and still become an SEO migration failure.
That made metadata part of our normalized content model rather than an afterthought.
A large migration needs an SEO-first web architecture so clean URLs, metadata, structured data, redirects, and crawlability survive the platform change.
Validation Became More Important Than Conversion
Eventually, most of our confidence came from automated validation.
For every expected URL, we checked:
Route exists?
↓
Correct status?
↓
Title present?
↓
Canonical correct?
↓
Content present?
↓
Images valid?
↓
Internal links valid?
We also made certain errors fail the build:
- missing expected routes,
- duplicate slugs,
- unknown shortcodes,
- broken internal links,
- missing canonicals,
- redirect loops.
Our rule became:
If a machine can prove the migration is broken, a human should not need to discover it after launch.
The Cutover Needed a Delta Migration
A large migration does not happen instantly.
Editors may continue publishing while the migration runs.
That means the original export quickly becomes outdated.
Our cutover flow looked roughly like:
Full migration
↓
Validate
↓
Sync recent changes
↓
Short publishing freeze
↓
Final delta sync
↓
Deploy
↓
Production validation
We also kept WordPress available during the rollback window.
A migration is safer when the previous production system remains recoverable.
What Went Wrong
The biggest mistakes were straightforward.
We started with components instead of inventory
Building UI felt productive, but understanding the existing site was more important.
We underestimated legacy content
Forty thousand pages created over many years do not follow one clean format.
We assumed Markdown should be the destination
Sometimes preserving HTML was safer.
We treated redirects as configuration
They were actually migration data.
We assumed static generation solved every performance problem
Runtime performance and build performance are different concerns.
We manually checked pages instead of validating the system
Automation gave us much more confidence than random visual QA.
What Worked
The strongest decisions were:
- inventory every public URL,
- normalize WordPress data before Astro sees it,
- migrate page types instead of individual pages,
- preserve URLs whenever possible,
- track assets separately,
- validate automatically,
- fail on unknown legacy content,
- plan the final content sync early.
The final architecture became:
WordPress
↓
Extract
↓
Normalize
↓
┌─────────────┐
│ Content │
│ Assets │
│ Redirects │
└─────────────┘
↓
Astro
↓
Validation
↓
Production
The Biggest Lesson
The hardest part of a WordPress to Astro migration was not Astro.
It was WordPress history.
The old system represented much more than PHP and a database.
It contained:
40,000 URLs
years of content
SEO history
redirects
media
plugins
shortcodes
custom markup
undocumented assumptions
Astro did not need to reproduce WordPress.
It needed to preserve the parts users and search engines depended on.
Final Takeaway
For a large WordPress to Astro migration, do not start with:
npm create astro@latest
Start with three questions:
What exists today?
What must survive?
How will we prove it survived?
Then build the migration around:
Inventory
↓
Extract
↓
Normalize
↓
Transform
↓
Generate
↓
Validate
↓
Deploy
The framework was the new delivery layer.
The real engineering work was migrating 40,000 content contracts without breaking them.
Top comments (0)