WordPress to Astro Migration Guide: 7 Steps for 2026
Astro is the best WordPress replacement for content-heavy sites in 2026 -- blog, docs, marketing pages -- because it ships zero JavaScript by default and beats WordPress on Core Web Vitals by 60-80 points. I've migrated a dozen WordPress sites to Astro over the past two years, ranging from 50-post personal blogs to 3,000-page documentation portals, and the results are consistently dramatic: sub-second load times, Lighthouse scores above 95, and hosting bills that drop from $99/month to literally zero.
This isn't a "just export XML and pray" guide. I'm going to walk you through the exact process I use, including the gotchas that'll bite you if you don't plan for them -- broken URLs, missing images, orphaned shortcodes, and the comment system question that trips everyone up.
Table of Contents
- Why Astro Instead of WordPress
- Astro vs Next.js vs Gatsby for WordPress Migration
- 7-Step Migration Playbook
- WordPress Data Export to Astro Content Collections
- Preserving SEO: 301 Redirects, Sitemap, and Schema
- Hosting Cost Comparison
- FAQ
Why Astro Instead of WordPress
WordPress is overengineered for most content sites. You're running PHP, MySQL, a web server, a caching layer, and probably a dozen plugins just to serve what's fundamentally static content. Every page load hits a database. Every plugin is a potential security hole. Every update is a prayer that nothing breaks.
Astro flips this model. It pre-renders your pages to static HTML at build time. No database. No server-side runtime. No PHP. The output is plain HTML, CSS, and -- only when you explicitly opt in -- JavaScript.
Here's what I consistently see across migrations:
| Metric | WordPress (Managed) | Astro (Static) | Improvement |
|---|---|---|---|
| Lighthouse Performance | 34-55 | 95-100 | +60-80 pts |
| First Contentful Paint | 2.8-4.2s | 0.4-0.8s | ~80% faster |
| Total Blocking Time | 200-800ms | 0-10ms | ~98% reduction |
| Page Size (typical blog post) | 1.5-3.5 MB | 80-250 KB | ~90% smaller |
| Time to Interactive | 4-8s | 0.5-1.0s | ~85% faster |
| HTTP Requests | 60-130 | 8-15 | ~85% fewer |
These aren't cherry-picked. They're averages from real migrations. WordPress sites with caching plugins and CDNs still can't touch a static Astro build because they're fundamentally doing more work per request.
The Security Angle
WordPress is the most attacked CMS on the internet. Not because it's bad, but because it's everywhere and it has a massive attack surface -- PHP execution, database access, file uploads, XML-RPC, REST API endpoints, admin login pages. Every month brings new plugin vulnerabilities.
Astro sites deployed to a CDN have essentially zero attack surface. There's no server to exploit, no admin panel to brute-force, no database to inject into. Your site is a folder of HTML files sitting on a global edge network.
The Developer Experience
If you've ever tried to customize a WordPress theme, you know the pain: PHP template tags mixed with HTML, the template hierarchy that requires memorization, functions.php files that grow into unmaintainable monsters, constant plugin conflicts.
Astro uses a component-based architecture with a file format that looks like HTML with superpowers. You can use React, Vue, Svelte, or Solid components inside Astro pages -- but only when you actually need interactivity. For a blog or marketing site, you probably don't.
---
// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BlogLayout title={post.data.title} description={post.data.excerpt}>
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.date.toISOString()}>
{post.data.date.toLocaleDateString()}
</time>
<Content />
</article>
</BlogLayout>
That's a complete dynamic blog post page. Try doing that in WordPress with the same clarity.
Astro vs Next.js vs Gatsby for WordPress Migration
Astro
Astro was purpose-built for content sites. Its "islands architecture" means you ship zero JS unless a specific component needs it. Content collections give you type-safe markdown handling with built-in schema validation. Build times are fast, the mental model is simple, and you don't need to understand React to use it. For blogs, docs, and marketing sites, it's the obvious choice in 2026. If you're exploring this route, our Astro development team has handled migrations at every scale.
Next.js
Next.js is a full application framework. It handles authentication, server-side rendering, API routes, middleware, and a hundred other things you don't need for a blog. You'll ship the React runtime to every visitor whether they need interactivity or not (App Router's server components help, but the baseline bundle is still heavier). Next.js makes sense when you're building a SaaS product or a site with heavy dynamic functionality -- user dashboards, e-commerce, real-time features. For a content migration from WordPress? It's overkill. That said, if your site does need those features, our Next.js team can help you figure out the right architecture.
Gatsby
Gatsby is effectively in maintenance mode. Netlify acquired it in 2023, and development has slowed to a crawl. The GraphQL data layer that once seemed clever now feels like unnecessary complexity. Build times for large sites are painful. The plugin ecosystem is stale. I'd strongly recommend against starting a new Gatsby project in 2026. If you're currently on Gatsby, migrating to Astro is actually easier than migrating from WordPress because your content is likely already in markdown.
| Feature | Astro | Next.js | Gatsby |
|---|---|---|---|
| Default JS shipped | 0 KB | ~85-100 KB | ~70-90 KB |
| Content collections | Built-in, type-safe | Manual setup | GraphQL layer |
| Build time (1000 posts) | ~30-45s | ~60-90s | ~120-300s |
| Learning curve | Low | Medium-High | Medium |
| Best for | Content sites | Web apps | Legacy projects |
| Active development | Very active | Very active | Minimal |
| SSR support | Optional | Default | Limited |
7-Step Migration Playbook
Here's the exact process I follow. No hand-waving.
Step 1: Audit Your WordPress Site
Before you touch any code, you need to know what you're working with. Log into your WordPress admin and take inventory.
# If you have WP-CLI installed (you should)
wp post list --post_type=post --format=csv --fields=ID,post_title,post_name,post_date > posts.csv
wp post list --post_type=page --format=csv --fields=ID,post_title,post_name,post_date > pages.csv
wp plugin list --format=table
wp theme list --format=table
Document every plugin and what it does. You'll need to find Astro equivalents or drop them. Common ones:
- Yoast SEO → Astro's built-in
<head>management +@astrojs/sitemap - Contact Form 7 → Formspree, Formspark, or a serverless function
- WP Super Cache / W3 Total Cache → Not needed (your site is already static)
- Wordfence / Sucuri → Not needed (no server to protect)
- Google Analytics plugin → Direct script tag or Partytown integration
- WooCommerce → Snipcart, Shopify Buy Button, or a dedicated e-commerce platform
Step 2: Export WordPress Content
You've got two options: XML export or REST API. I recommend the XML export for most sites because it captures everything in one shot.
In WordPress admin: Tools → Export → All Content → Download Export File
This gives you a .xml file containing every post, page, comment, custom field, category, tag, and media reference.
For larger sites (1000+ posts), the REST API approach is more reliable:
// scripts/export-wp.mjs
import fs from 'fs/promises';
import path from 'path';
const WP_URL = 'https://your-wordpress-site.com/wp-json/wp/v2';
const PER_PAGE = 100;
async function fetchAllPosts() {
let page = 1;
let allPosts = [];
while (true) {
const res = await fetch(
`${WP_URL}/posts?per_page=${PER_PAGE}&page=${page}&_embed`
);
if (!res.ok) break;
const posts = await res.json();
if (posts.length === 0) break;
allPosts = allPosts.concat(posts);
console.log(`Fetched page ${page} (${allPosts.length} posts total)`);
page++;
}
return allPosts;
}
const posts = await fetchAllPosts();
await fs.writeFile('wp-posts.json', JSON.stringify(posts, null, 2));
console.log(`Exported ${posts.length} posts`);
Step 3: Scaffold Your Astro Project
npm create astro@latest my-new-site
cd my-new-site
# Add the integrations you'll need
npx astro add mdx
npx astro add sitemap
npx astro add tailwind
# Install additional dependencies
npm install sharp @astrojs/rss
I recommend starting with a minimal setup rather than a theme. Themes add complexity you don't need during migration. Get the content working first, then style it.
Step 4: Convert Content to Markdown
This is where the real work happens. You need to convert WordPress HTML content into clean markdown with proper frontmatter.
I use a custom Node.js script with turndown for HTML-to-markdown conversion:
npm install turndown @wordpress/block-serialization-default-parser
// scripts/convert-posts.mjs
import TurndownService from 'turndown';
import fs from 'fs/promises';
import path from 'path';
const turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
});
// Handle WordPress-specific HTML
turndown.addRule('wpCaption', {
filter: (node) => {
return node.nodeName === 'DIV' &&
node.className.includes('wp-caption');
},
replacement: (content, node) => {
const img = node.querySelector('img');
const caption = node.querySelector('.wp-caption-text');
return `\n`;
},
});
const posts = JSON.parse(await fs.readFile('wp-posts.json', 'utf-8'));
for (const post of posts) {
const slug = post.slug;
const markdown = turndown.turndown(post.content.rendered);
const frontmatter = `---
title: "${post.title.rendered.replace(/"/g, '\\"')}"
date: ${post.date}
excerpt: "${(post.excerpt.rendered || '').replace(/<[^>]*>/g, '').trim().replace(/"/g, '\\"')}"
categories: [${(post._embedded?.['wp:term']?.[0] || []).map(c => `"${c.name}"`).join(', ')}]
tags: [${(post._embedded?.['wp:term']?.[1] || []).map(t => `"${t.name}"`).join(', ')}]
featuredImage: "${post._embedded?.['wp:featuredmedia']?.[0]?.source_url || ''}"
draft: false
---`;
const content = `${frontmatter}\n\n${markdown}\n`;
await fs.mkdir('src/content/blog', { recursive: true });
await fs.writeFile(`src/content/blog/${slug}.md`, content);
console.log(`Converted: ${slug}`);
}
Step 5: Download and Organize Media
WordPress stores media in wp-content/uploads/YYYY/MM/ directories. You need to download everything and update references.
# Quick and dirty: wget mirror of your uploads directory
wget -r -np -nH --cut-dirs=2 -P public/uploads \
https://your-wordpress-site.com/wp-content/uploads/
# Then find-and-replace old URLs in your markdown files
find src/content/blog -name '*.md' -exec sed -i '' \
's|https://your-wordpress-site.com/wp-content/uploads/|/uploads/|g' {} +
For production migrations, I use Astro's image optimization with the sharp library to convert everything to WebP and generate responsive sizes at build time. This alone can cut image payload by 40-60%.
Step 6: Build Layouts and Components
Create your Astro layouts to match (or improve upon) your WordPress theme structure. At minimum you'll need:
src/layouts/BaseLayout.astro-- HTML shell,<head>, nav, footersrc/layouts/BlogLayout.astro-- Single post templatesrc/pages/blog/index.astro-- Blog listing with paginationsrc/pages/blog/[...slug].astro-- Dynamic post pagessrc/pages/index.astro-- Homepage
Step 7: Test, Redirect, Deploy
Build locally and verify everything:
npm run build
npm run preview
# Check for broken links
npx linkinator http://localhost:4321 --recurse
Set up redirects (covered in detail below), configure your DNS, and deploy. I'll cover hosting options in the cost comparison section.
WordPress Data Export to Astro Content Collections
Astro's content collections are one of its best features. They give you type-safe access to your markdown content with schema validation. Here's how to set them up properly for migrated WordPress content.
Defining Your Schema
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
excerpt: z.string().optional(),
categories: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
featuredImage: z.string().optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
The beauty of this: if any of your migrated posts have missing or malformed frontmatter, Astro will tell you at build time with a clear error message. No more silent failures.
Handling WordPress Shortcodes
This is the gotcha that most migration guides skip. If your WordPress posts use shortcodes like [gallery], [caption], [embed], or any custom shortcodes from plugins, they'll show up as raw text in your markdown.
You've got three options:
- Pre-process during conversion -- Write regex rules in your conversion script to transform shortcodes into markdown or HTML equivalents
- Use MDX -- Convert affected posts to
.mdxand create Astro components that replace shortcodes - Find and replace manually -- For small sites, sometimes the fastest approach
// Example: converting WordPress gallery shortcode during conversion
function convertShortcodes(content) {
// [gallery ids="1,2,3"]
content = content.replace(
/\[gallery ids="([^"]+)"\]/g,
(match, ids) => {
const imageIds = ids.split(',');
return imageIds.map(id => `}.jpg)`).join('\n');
}
);
// [youtube url="..."]
content = content.replace(
/\[youtube[^\]]*url="([^"]+)"[^\]]*\]/g,
(match, url) => `<iframe src="${url}" width="560" height="315" frameborder="0"></iframe>`
);
return content;
}
Querying Content
Once your content is in collections, querying it is straightforward:
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
const posts = (await getCollection('blog'))
.filter(post => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---
<BlogLayout title="Blog">
<ul>
{posts.map(post => (
<li>
<a href={`/blog/${post.slug}/`}>
<h2>{post.data.title}</h2>
<time>{post.data.date.toLocaleDateString()}</time>
<p>{post.data.excerpt}</p>
</a>
</li>
))}
</ul>
</BlogLayout>
Preserving SEO: 301 Redirects, Sitemap, and Schema
This section is critical. A botched migration can destroy years of SEO equity overnight. Don't skip any of this.
URL Structure and 301 Redirects
WordPress defaults to URLs like /2024/03/my-post-title/ or /?p=123. You need to map every old URL to its new Astro equivalent.
First, generate a complete list of old URLs:
# Using WP-CLI
wp post list --post_type=post,page --field=url > old-urls.txt
# Or crawl the sitemap
curl -s https://your-site.com/sitemap.xml | grep -oP '<loc>\K[^<]+'
For Vercel, create a vercel.json in your project root:
{
"redirects": [
{ "source": "/2024/03/my-old-post/", "destination": "/blog/my-old-post/", "permanent": true },
{ "source": "/:year(\\d{4})/:month(\\d{2})/:slug/", "destination": "/blog/:slug/", "permanent": true },
{ "source": "/category/:slug/", "destination": "/blog/category/:slug/", "permanent": true },
{ "source": "/feed/", "destination": "/rss.xml", "permanent": true }
]
}
For Netlify, use _redirects in your public/ folder:
/2024/03/my-old-post/ /blog/my-old-post/ 301
/category/* /blog/category/:splat 301
/feed/ /rss.xml 301
For Cloudflare Pages, use _redirects (same format as Netlify) or Bulk Redirects in the dashboard.
Sitemap Generation
The @astrojs/sitemap integration handles this automatically:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://your-new-site.com',
integrations: [sitemap()],
});
After deploying, submit your new sitemap in Google Search Console immediately. Also submit a removal request for any URLs that no longer exist and aren't redirected.
Structured Data / Schema Markup
If Yoast was handling your schema markup, you need to replicate it. Create a reusable component:
---
// src/components/ArticleSchema.astro
const { title, date, excerpt, image, author = 'Your Name' } = Astro.props;
const schema = {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": title,
"datePublished": date,
"description": excerpt,
"image": image,
"author": {
"@type": "Person",
"name": author
}
};
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
RSS Feed
Don't forget your RSS subscribers:
// src/pages/rss.xml.js
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: 'Your Site',
description: 'Your description',
site: context.site,
items: posts.map(post => ({
title: post.data.title,
pubDate: post.data.date,
description: post.data.excerpt,
link: `/blog/${post.slug}/`,
})),
});
}
Hosting Cost Comparison
This is where the business case for migration becomes impossible to ignore.
| Hosting Scenario | Monthly Cost | Annual Cost | Notes |
|---|---|---|---|
| WP Engine (Managed WordPress) | $30-60 | $360-720 | Startup-Growth plans |
| Kinsta (Managed WordPress) | $35-70 | $420-840 | Starter-Business plans |
| Flywheel (Managed WordPress) | $15-30 | $180-360 | Tiny-Personal plans |
| Self-hosted (DigitalOcean + maintenance) | $12-24 | $144-288 | Plus your time for updates |
| Astro on Vercel (Hobby) | $0 | $0 | 100GB bandwidth/mo |
| Astro on Netlify (Free) | $0 | $0 | 100GB bandwidth/mo |
| Astro on Cloudflare Pages (Free) | $0 | $0 | Unlimited bandwidth |
| Astro on Vercel (Pro) | $20 | $240 | 1TB bandwidth, team features |
For the vast majority of content sites -- blogs, portfolios, documentation sites, marketing sites -- the free tiers on Vercel, Netlify, or Cloudflare Pages are more than sufficient. You're serving static files from a global CDN. A site getting 100,000 pageviews per month will barely scratch the free tier limits.
Let me spell out the math. If you're paying $50/month for managed WordPress hosting, that's $600/year. Over three years, that's $1,800. Add premium plugin licenses (Yoast Premium at $99/year, Elementor Pro at $59/year, WP Rocket at $59/year), and you're looking at $800-$1,000 annually for a content site that could be hosted for free.
The migration itself is a one-time cost. If you handle it yourself, it's your time. If you hire a team like ours through our headless CMS development services, the ROI typically breaks even within 6-12 months just from hosting savings alone -- not counting the performance and SEO improvements. Check our pricing page for specifics.
But What About Dynamic Features?
The most common objection: "My WordPress site has contact forms, search, comments, and e-commerce."
- Contact forms: Formspree (free tier: 50 submissions/month), Formspark, or a simple serverless function
- Search: Pagefind (free, runs entirely client-side, built for static sites) -- it's incredible
- Comments: Giscus (free, GitHub-backed), or Disqus if you must
- E-commerce: Snipcart, Shopify Lite, or Stripe Checkout
- Newsletter signups: Direct API calls to ConvertKit, Mailchimp, or Buttondown
None of these require a server. None of them add meaningful cost.
FAQ
How long does WordPress to Astro migration take?
For a typical blog with 50-200 posts, expect 1-2 weeks of part-time work if you're doing it yourself. The content conversion is usually a day's work with a good script. Building the Astro layouts and components takes 2-4 days depending on design complexity. Testing, redirect setup, and deployment take another 1-2 days. For larger sites (500+ posts) or sites with complex custom post types and plugin dependencies, budget 3-6 weeks. If you'd rather hand it off, reach out to our team -- we typically complete migrations in 2-4 weeks.
Can Astro handle a WordPress blog with 1000+ posts?
Absolutely. I've migrated sites with over 3,000 posts and build times stayed under 2 minutes. Content collections are optimized for large datasets, and since everything compiles to static HTML, there's no runtime performance penalty regardless of content volume. The build step is the only place where scale matters, and Astro's build performance is excellent -- significantly faster than Gatsby or Next.js for static content at scale.
Does Astro support WordPress comments?
Not natively, and honestly that's a feature, not a bug. WordPress comments are a spam magnet that requires constant moderation. For Astro sites, the best options are: Giscus (uses GitHub Discussions -- free, no tracking, great for developer audiences), Disqus (the old standby, has ads on free tier), Commento (privacy-focused, $5/month), or a custom solution using a database like Turso or Supabase with an Astro API route. If you want to preserve existing WordPress comments, export them and display them as static content, then use one of these services for new comments going forward.
Is Astro faster than WordPress?
It's not even close. A static Astro site will outperform even the most optimized WordPress setup because it eliminates the fundamental bottleneck: server-side processing. WordPress has to execute PHP, query a database, assemble the page, and send it -- for every request (unless cached). Astro pre-builds everything. Your visitors receive pre-rendered HTML directly from a CDN edge node near them. Typical improvements are 80-90% faster load times and 60-80 point Lighthouse score improvements.
What happens to my WordPress admin and WYSIWYG editor?
You lose the WordPress admin panel. For most developers, that's a relief. You'll edit markdown files directly, which is faster and more portable. If you've got non-technical content editors who need a visual interface, consider the headless approach: keep WordPress running as a content backend and use Astro as the frontend. Or pair Astro with a headless CMS like Sanity, Contentful, Storyblok, or Keystatic (which has a GitHub-based editor that's genuinely nice). Our headless CMS development services can help you pick the right content backend for your team.
Will my Google rankings drop after migrating?
They shouldn't, and in most cases they improve -- but only if you handle redirects correctly. Every old URL must either still work or 301-redirect to its new equivalent. Submit your new sitemap to Google Search Console the same day you launch. Monitor the Index Coverage report for the first 30 days. I've seen sites gain 15-30% organic traffic within 2-3 months of migration purely from Core Web Vitals improvements, since Google uses page experience signals as a ranking factor.
Can I keep WordPress as a headless CMS and use Astro for the frontend?
Yes, and this is a great middle-ground approach. You keep the WordPress admin and editor experience but ditch the PHP frontend entirely. Astro fetches content from the WordPress REST API (or WPGraphQL) at build time and generates static pages. You still need to host WordPress somewhere, so you won't save on hosting costs, but you get all the frontend performance benefits. For teams that are heavily invested in the WordPress editing workflow, this is often the pragmatic choice.
Do I need to know React or any JavaScript framework to use Astro?
No. Astro components use a .astro file format that looks like HTML with a frontmatter script block. If you can write HTML and CSS, you can build an Astro site. JavaScript frameworks (React, Vue, Svelte) are optional -- you'd only bring them in if you need interactive client-side components like a search widget, a form with validation, or an image carousel. For a blog or marketing site, you can build the entire thing without touching React.