Moving from WordPress to Astro might seem daunting, but with the right approach, it’s a straightforward process that can dramatically improve your site’s performance. This guide walks through the exact steps we took to migrate from WordPress to Astro, preserving all content and maintaining SEO through proper redirects.
Why Migrate from WordPress to Astro?
Before diving into the technical details, it’s worth understanding why this migration made sense. WordPress is a powerful platform, but it comes with overhead—database queries, PHP processing, plugin management, and security updates. Astro, on the other hand, generates static HTML at build time, resulting in blazing-fast page loads and minimal hosting requirements.
The benefits we experienced:
- 10x faster page loads - Static HTML served directly from a CDN
- Simplified hosting - No database, no PHP, just static files
- Better developer experience - Modern JavaScript tooling and component-based architecture
- Improved security - No dynamic backend means fewer attack vectors
- Lower costs - Static hosting is significantly cheaper than managed WordPress
Step 1: Export Content from WordPress
WordPress provides a REST API endpoint that makes exporting content straightforward. The /wp-json/wp/v2/posts endpoint returns all your published posts in JSON format.
First, let’s fetch all the posts:
// fetch-wordpress-posts.js
const fetch = require('node-fetch');
const WORDPRESS_URL = 'https://your-old-site.com';
const PER_PAGE = 100;
async function fetchAllPosts() {
let page = 1;
let allPosts = [];
let hasMore = true;
while (hasMore) {
const response = await fetch(
`${WORDPRESS_URL}/wp-json/wp/v2/posts?per_page=${PER_PAGE}&page=${page}`
);
if (!response.ok) {
console.error('Failed to fetch posts:', response.statusText);
break;
}
const posts = await response.json();
allPosts = allPosts.concat(posts);
// Check if there are more pages
const totalPages = parseInt(response.headers.get('x-wp-totalpages'));
hasMore = page < totalPages;
page++;
console.log(`Fetched page ${page - 1} of ${totalPages}`);
}
return allPosts;
}
async function main() {
console.log('Fetching WordPress posts...');
const posts = await fetchAllPosts();
// Save to a file for processing
const fs = require('fs');
fs.writeFileSync('wordpress-posts.json', JSON.stringify(posts, null, 2));
console.log(`Successfully exported ${posts.length} posts`);
}
main().catch(console.error);
Run this script with Node.js:
node fetch-wordpress-posts.js
This creates a wordpress-posts.json file containing all your posts with their metadata, content, and WordPress-specific fields.
Step 2: Convert Posts to Markdown
With your WordPress data exported, the next step is converting each post to Markdown format with Astro-compatible frontmatter. WordPress stores content as HTML, so we’ll need to handle the conversion carefully.
// convert-to-markdown.js
const fs = require('fs');
const path = require('path');
const TurndownService = require('turndown');
// Initialize HTML to Markdown converter
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-'
});
// Custom rules for better conversion
turndownService.addRule('removeComments', {
filter: function (node) {
return node.nodeName === '#comment';
},
replacement: function () {
return '';
}
});
function sanitizeFilename(title) {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function convertPost(post) {
// Extract key fields
const title = post.title.rendered;
const date = post.date.split('T')[0]; // Format: YYYY-MM-DD
const content = post.content.rendered;
const excerpt = post.excerpt.rendered
.replace(/<[^>]*>/g, '') // Strip HTML
.replace(/&[^;]+;/g, '') // Strip HTML entities
.trim();
// Convert HTML content to Markdown
const markdown = turndownService.turndown(content);
// Generate filename from slug or title
const filename = post.slug || sanitizeFilename(title);
// Build frontmatter
const frontmatter = `---
title: "${title.replace(/"/g, '\\"')}"
date: ${date}
excerpt: "${excerpt.replace(/"/g, '\\"')}"
---
`;
return {
filename: `${filename}.md`,
content: frontmatter + markdown,
slug: post.slug
};
}
function main() {
// Read the exported posts
const postsData = JSON.parse(fs.readFileSync('wordpress-posts.json', 'utf-8'));
// Create output directory
const outputDir = path.join(__dirname, 'src', 'content', 'blog');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Convert each post
const redirectMap = [];
postsData.forEach((post, index) => {
const converted = convertPost(post);
const outputPath = path.join(outputDir, converted.filename);
// Write the markdown file
fs.writeFileSync(outputPath, converted.content);
// Store redirect mapping for later
redirectMap.push({
from: new URL(post.link).pathname,
to: `/blog/post/${converted.slug}`
});
console.log(`Converted ${index + 1}/${postsData.length}: ${converted.filename}`);
});
// Save redirect mappings
fs.writeFileSync('redirect-map.json', JSON.stringify(redirectMap, null, 2));
console.log(`\nSuccessfully converted ${postsData.length} posts!`);
console.log('Redirect map saved to redirect-map.json');
}
main();
Install the required dependency:
npm install turndown
Then run the conversion:
node convert-to-markdown.js
This script does several things:
- Reads the WordPress posts JSON
- Converts HTML content to Markdown using Turndown
- Generates proper frontmatter for Astro
- Creates clean filenames from post slugs
- Saves a redirect mapping for the next step
Step 3: Set Up Redirects
This is crucial for maintaining SEO and not breaking existing links. WordPress typically uses URLs like /2023/12/my-post-title/ while Astro might use /blog/post/my-post-title. We need to handle these redirects.
If you’re using Netlify, create a _redirects file:
// generate-redirects.js
const fs = require('fs');
function generateNetlifyRedirects() {
const redirectMap = JSON.parse(fs.readFileSync('redirect-map.json', 'utf-8'));
let redirects = '# WordPress to Astro redirects\n\n';
redirectMap.forEach(({ from, to }) => {
redirects += `${from} ${to} 301\n`;
});
// Handle common WordPress patterns
redirects += '\n# Catch-all for WordPress date-based URLs\n';
redirects += '/2* /blog/:splat 301\n';
redirects += '\n# WordPress feed redirects\n';
redirects += '/feed /blog/rss.xml 301\n';
redirects += '/feed/ /blog/rss.xml 301\n';
fs.writeFileSync('public/_redirects', redirects);
console.log('Generated Netlify _redirects file');
}
generateNetlifyRedirects();
For Vercel, use vercel.json:
{
"redirects": [
{
"source": "/2023/12/my-old-post",
"destination": "/blog/post/my-old-post",
"permanent": true
}
]
}
Or handle redirects in Astro middleware for more control:
// src/middleware/redirects.js
import redirectMap from '../../redirect-map.json';
export function onRequest({ request, redirect }, next) {
const url = new URL(request.url);
const pathname = url.pathname;
// Check if this path needs redirecting
const redirectEntry = redirectMap.find(r => r.from === pathname);
if (redirectEntry) {
return redirect(redirectEntry.to, 301);
}
return next();
}
Step 4: Migrate Images and Media
WordPress stores images in the uploads directory. You’ll need to download these and reference them in your Astro project.
// download-images.js
const fs = require('fs');
const path = require('path');
const https = require('https');
async function downloadImage(url, filepath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(filepath);
https.get(url, response => {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', err => {
fs.unlink(filepath, () => {});
reject(err);
});
});
}
async function migrateImages() {
const postsData = JSON.parse(fs.readFileSync('wordpress-posts.json', 'utf-8'));
const imageDir = path.join(__dirname, 'public', 'images', 'blog');
if (!fs.existsSync(imageDir)) {
fs.mkdirSync(imageDir, { recursive: true });
}
for (const post of postsData) {
// Extract image URLs from post content
const imgRegex = /<img[^>]+src="([^">]+)"/g;
let match;
while ((match = imgRegex.exec(post.content.rendered)) !== null) {
const imageUrl = match[1];
const filename = path.basename(new URL(imageUrl).pathname);
const filepath = path.join(imageDir, filename);
try {
if (!fs.existsSync(filepath)) {
await downloadImage(imageUrl, filepath);
console.log(`Downloaded: ${filename}`);
}
} catch (err) {
console.error(`Failed to download ${imageUrl}:`, err.message);
}
}
}
console.log('Image migration complete!');
}
migrateImages().catch(console.error);
Step 5: Update Internal Links
After migration, update any internal links in your content that still point to WordPress URLs:
// update-internal-links.js
const fs = require('fs');
const path = require('path');
const OLD_DOMAIN = 'https://your-old-site.com';
const blogDir = path.join(__dirname, 'src', 'content', 'blog');
fs.readdirSync(blogDir).forEach(filename => {
if (!filename.endsWith('.md')) return;
const filepath = path.join(blogDir, filename);
let content = fs.readFileSync(filepath, 'utf-8');
// Replace old domain links with relative paths
const updated = content.replace(
new RegExp(`${OLD_DOMAIN}/([^)\\s]+)`, 'g'),
'/$1'
);
if (updated !== content) {
fs.writeFileSync(filepath, updated);
console.log(`Updated links in: ${filename}`);
}
});
console.log('Internal link update complete!');
Step 6: Verify and Test
Before going live, thoroughly test your migration:
- Check all posts render correctly - Browse through your blog posts in development
- Verify images load - Ensure all media files were migrated successfully
- Test redirects - Use the old WordPress URLs to confirm they redirect properly
- Validate frontmatter - Make sure dates, titles, and excerpts are correct
- Review SEO metadata - Check that meta descriptions and titles match your WordPress setup
# Build and preview the site
npm run build
npm run preview
Additional Considerations
Categories and Tags
If you used WordPress categories and tags, you can preserve them in the frontmatter:
---
title: "My Post Title"
date: 2024-01-15
categories: ["Web Development", "JavaScript"]
tags: ["astro", "wordpress", "migration"]
---
Comments
WordPress comments need special handling. Options include:
- Migrate to a service like Disqus or Giscus
- Export comments and store them statically
- Use a database solution like Supabase
Authors and Multi-user Support
For multi-author blogs, add author information to frontmatter:
---
title: "My Post Title"
date: 2024-01-15
author: "Jesse Waites"
authorUrl: "https://jessewaites.com"
---
Performance Gains
After completing the migration, you should see significant improvements:
- Lighthouse scores - Expect near-perfect scores across all metrics
- First Contentful Paint - Often under 500ms with proper CDN setup
- Time to Interactive - Drastically reduced without JavaScript overhead
- Hosting costs - Static hosting is pennies compared to WordPress hosting
Conclusion
Migrating from WordPress to Astro requires some upfront work, but the long-term benefits are substantial. You gain a faster, more secure, and easier-to-maintain website while preserving all your content and SEO value.
The key steps are:
- Export content using the WordPress REST API
- Convert HTML to Markdown with proper frontmatter
- Set up comprehensive redirects
- Migrate images and media files
- Update internal links
- Test thoroughly before deployment
This approach ensures a smooth transition while maintaining your search engine rankings and providing your users with a faster, better experience. The modern developer experience of Astro, combined with the performance of static site generation, makes this migration well worth the effort.