WordPress powers a significant portion of the web, but for content-driven blogs and documentation sites, the overhead of a full PHP/MySQL stack is increasingly hard to justify. Static site generators like Astro deliver blazing-fast performance, virtually eliminate security vulnerabilities, and cost almost nothing to host.
This guide walks you through the complete process of migrating a WordPress blog to Astro — from exporting your content and converting it to Markdown, to preserving your SEO rankings and deploying on Cloudflare Pages. This is not theory: we migrated KnowledgeXchange.xyz (507 posts, 1,427 pages built in 11 seconds) using exactly this process.
Why Migrate from WordPress to Astro?
Performance
A typical WordPress page requires multiple PHP execution cycles, database queries, and plugin processing before delivering HTML to the browser. Even with aggressive caching, Time to First Byte (TTFB) rarely drops below 200-400ms.
Astro generates static HTML at build time. Pages load in under 50ms from a CDN edge node. There is no server processing, no database, and no PHP execution at request time.
Security
WordPress is the most targeted CMS on the internet. Every plugin, theme, and core update is a potential attack vector. SQL injection, cross-site scripting, brute-force login attacks, and file upload vulnerabilities require constant vigilance.
Static HTML files have virtually zero attack surface. There is no database to inject, no admin panel to brute-force, and no PHP to exploit.
Cost
A WordPress site typically requires a VPS ($5-50/month), managed hosting ($25-200/month), or shared hosting ($3-15/month). Add premium plugins, security tools, and CDN services.
An Astro site on Cloudflare Pages costs $0/month for most blogs. Even high-traffic sites stay within free tier limits because serving static files is incredibly cheap.
Developer Experience
Astro uses modern tooling: components written in .astro files (similar to HTML with JavaScript), Markdown/MDX for content, TypeScript support, and a blazing-fast Vite-based build system. If you have ever fought with WordPress’s template hierarchy, functions.php hooks, or plugin conflicts, you will appreciate the simplicity.
Understanding Astro
Astro is a modern static site generator designed for content-driven websites. Key features include:
- Content Collections — Type-safe Markdown/MDX content with schema validation
- Island Architecture — Ship zero JavaScript by default, hydrate interactive components only when needed
- Framework Agnostic — Use React, Vue, Svelte, or plain HTML components
- Built-in Optimizations — Automatic image optimization, CSS scoping, and HTML minification
- Fast Builds — Vite-powered build system that handles thousands of pages efficiently
Planning the Migration
Before touching any code, plan your migration strategy:
Content Inventory
- Count your posts, pages, and custom post types — Know the scope
- Catalog your media — Images, PDFs, videos stored in
wp-content/uploads - Document your URL structure —
/2024/01/my-post/or/category/my-post/or/my-post/ - List critical SEO pages — Top-performing pages that must maintain their rankings
- Identify dynamic features — Contact forms, comments, search, e-commerce
Feature Mapping
| WordPress Feature | Astro Equivalent |
|---|---|
| Posts/Pages | Markdown files in content collections |
| Categories/Tags | Frontmatter metadata + generated pages |
| Featured Images | Astro Image component |
| Comments | External service (Giscus, Disqus) or remove |
| Contact Forms | Cloudflare Workers, Formspree, or Netlify Forms |
| Search | Pagefind, Fuse.js, or Algolia |
| RSS Feed | @astrojs/rss integration |
| Sitemap | @astrojs/sitemap integration |
| SEO Metadata | Custom <head> components |
Step 1: Export WordPress Content
Using WordPress XML Export
From your WordPress admin dashboard:
- Navigate to Tools > Export
- Select All content
- Click Download Export File
This generates a WXR (WordPress eXtended RSS) XML file containing all posts, pages, comments, categories, tags, and media references.
Using the REST API (Alternative)
For more control, you can pull content via the WordPress REST API:
# Fetch all posts (paginated, 100 per page)
curl -s "https://knowledgexchange.xyz/wp-json/wp/v2/posts?per_page=100&page=1" > posts-page1.json
curl -s "https://knowledgexchange.xyz/wp-json/wp/v2/posts?per_page=100&page=2" > posts-page2.json
# Fetch all categories
curl -s "https://knowledgexchange.xyz/wp-json/wp/v2/categories?per_page=100" > categories.json
# Fetch all tags
curl -s "https://knowledgexchange.xyz/wp-json/wp/v2/tags?per_page=100" > tags.json
# Fetch media library
curl -s "https://knowledgexchange.xyz/wp-json/wp/v2/media?per_page=100&page=1" > media-page1.json
Step 2: Convert Content to Markdown
Node.js Migration Script
Here is a practical Node.js script that converts WordPress XML to Markdown files with Astro-compatible frontmatter:
// migrate.mjs
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { XMLParser } from 'fast-xml-parser';
import TurndownService from 'turndown';
import slugify from 'slugify';
const turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
});
// Handle WordPress-specific HTML patterns
turndown.addRule('preCode', {
filter: (node) => node.nodeName === 'PRE',
replacement: (content, node) => {
const code = node.querySelector('code');
const lang = code?.className?.replace('language-', '') || '';
const text = code ? code.textContent : node.textContent;
return `\n\`\`\`${lang}\n${text.trim()}\n\`\`\`\n`;
},
});
// Parse the WordPress XML export
const xml = readFileSync('wordpress-export.xml', 'utf-8');
const parser = new XMLParser({ ignoreAttributes: false });
const data = parser.parse(xml);
const items = data.rss.channel.item;
const posts = Array.isArray(items) ? items : [items];
const outputDir = './src/content/posts';
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
let count = 0;
for (const post of posts) {
// Skip non-published or non-post types if desired
const status = post['wp:status'];
const postType = post['wp:post_type'];
if (status !== 'publish' || postType !== 'post') continue;
const title = post.title?.toString() || 'Untitled';
const date = post['wp:post_date']?.split(' ')[0] || '2024-01-01';
const content = post['content:encoded'] || '';
const slug = post['wp:post_name'] || slugify(title, { lower: true, strict: true });
// Extract categories and tags
const cats = [];
const tags = [];
const taxonomy = Array.isArray(post.category) ? post.category : [post.category].filter(Boolean);
for (const term of taxonomy) {
if (term?.['@_domain'] === 'category') cats.push(term['#text'] || term);
if (term?.['@_domain'] === 'post_tag') tags.push(term['#text'] || term);
}
// Convert HTML to Markdown
const markdown = turndown.turndown(content);
// Build Astro frontmatter
const frontmatter = [
'---',
`title: "${title.replace(/"/g, '\\"')}"`,
`date: "${date}"`,
`lastModified: "${date}"`,
`categories: [${cats.map(c => `"${c}"`).join(', ')}]`,
`tags: [${tags.map(t => `"${t}"`).join(', ')}]`,
`author: "JC"`,
`slug: "${slug}"`,
`description: "${title.replace(/"/g, '\\"')}"`,
`lang: "en"`,
'---',
].join('\n');
const fileContent = `${frontmatter}\n\n${markdown}\n`;
writeFileSync(`${outputDir}/${slug}.md`, fileContent, 'utf-8');
count++;
}
console.log(`Migrated ${count} posts to ${outputDir}`);
Install dependencies and run:
npm install fast-xml-parser turndown slugify
node migrate.mjs
Python Alternative
If you prefer Python:
#!/usr/bin/env python3
"""migrate_wp.py -- Convert WordPress XML export to Astro Markdown files."""
import xml.etree.ElementTree as ET
import os
import re
from html2text import HTML2Text
WP_NS = {
'wp': 'http://wordpress.org/export/1.2/',
'content': 'http://purl.org/rss/1.0/modules/content/',
'excerpt': 'http://wordpress.org/export/1.2/excerpt/',
}
h2t = HTML2Text()
h2t.body_width = 0 # Don't wrap lines
h2t.protect_links = True
tree = ET.parse('wordpress-export.xml')
root = tree.getroot()
channel = root.find('channel')
output_dir = './src/content/posts'
os.makedirs(output_dir, exist_ok=True)
count = 0
for item in channel.findall('item'):
status = item.find('wp:status', WP_NS).text
post_type = item.find('wp:post_type', WP_NS).text
if status != 'publish' or post_type != 'post':
continue
title = item.find('title').text or 'Untitled'
date = item.find('wp:post_date', WP_NS).text.split(' ')[0]
slug = item.find('wp:post_name', WP_NS).text
content_html = item.find('content:encoded', WP_NS).text or ''
# Convert HTML to Markdown
markdown = h2t.handle(content_html)
frontmatter = f"""---
title: "{title.replace('"', '\\"')}"
date: "{date}"
slug: "{slug}"
author: "JC"
lang: "en"
---"""
filepath = os.path.join(output_dir, f'{slug}.md')
with open(filepath, 'w', encoding='utf-8') as f:
f.write(f'{frontmatter}\n\n{markdown}\n')
count += 1
print(f'Migrated {count} posts')
Step 3: Handle Images
WordPress stores images in wp-content/uploads/YYYY/MM/ directories. You have several options:
Option A: Download and Store Locally
# Download all images referenced in your posts
wget --mirror --no-parent --reject "index.html*" \
https://knowledgexchange.xyz/wp-content/uploads/ \
-P ./public/wp-content/uploads/
Then update image references in your Markdown files:
# Replace absolute WordPress URLs with relative paths
find ./src/content/posts -name "*.md" -exec sed -i \
's|https://knowledgexchange.xyz/wp-content/uploads/|/wp-content/uploads/|g' {} +
Option B: Use a CDN (Recommended)
Keep images on an external CDN like Cloudflare R2, AWS S3, or Cloudinary. This keeps your repository small and build times fast:
// In your Astro component, reference external images
// src/components/PostImage.astro
---
const { src, alt, width, height } = Astro.props;
const cdnUrl = `https://cdn.knowledgexchange.xyz/images/${src}`;
---
<img src={cdnUrl} alt={alt} width={width} height={height} loading="lazy" />
Step 4: Preserve URL Structure
Maintaining your existing URL structure is critical for SEO. Configure Astro routing to match your WordPress permalink format.
Dynamic Route for Posts
Create the file src/pages/post/[slug].astro:
---
import { getCollection } from 'astro:content';
import PostLayout from '../../layouts/PostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map((post) => ({
params: { slug: post.data.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<PostLayout frontmatter={post.data}>
<Content />
</PostLayout>
Handling Redirects for Changed URLs
If your URL structure changes, create a _redirects file for Cloudflare Pages:
# Redirect old WordPress URLs to new Astro URLs
/2024/01/my-old-post/ /post/my-old-post/ 301
/category/linux/ /categories/linux/ 301
/tag/ubuntu/ /tags/ubuntu/ 301
# Redirect WordPress admin and feed URLs
/wp-admin/* / 301
/wp-login.php / 301
/feed/ /rss.xml 301
Step 5: Set Up Astro Content Collections
Define your content schema in src/content.config.ts:
import { defineCollection, z } from 'astro:content';
const posts = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
date: z.string(),
lastModified: z.string().optional(),
categories: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
author: z.string().default('JC'),
slug: z.string(),
description: z.string().optional(),
lang: z.enum(['en', 'es']).default('en'),
difficulty: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
featured: z.boolean().default(false),
readingTime: z.number().optional(),
}),
});
export const collections = { posts };
Tip: Astro validates every Markdown file against this schema at build time. If a migrated post has missing or malformed frontmatter, the build will fail with a clear error message pointing to the exact file and field.
Step 6: Design Layouts and Components
Post Layout
---
// src/layouts/PostLayout.astro
const { frontmatter } = Astro.props;
---
<html lang={frontmatter.lang || 'en'}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{frontmatter.title} | KnowledgeXchange</title>
<meta name="description" content={frontmatter.description || frontmatter.title} />
<meta property="og:title" content={frontmatter.title} />
<meta property="og:description" content={frontmatter.description || frontmatter.title} />
<meta property="og:type" content="article" />
<meta property="article:published_time" content={frontmatter.date} />
<meta property="article:modified_time" content={frontmatter.lastModified || frontmatter.date} />
<link rel="canonical" href={`https://www.knowledgexchange.xyz/post/${frontmatter.slug}/`} />
</head>
<body>
<article>
<header>
<h1>{frontmatter.title}</h1>
<time datetime={frontmatter.date}>{frontmatter.date}</time>
<span>By {frontmatter.author}</span>
{frontmatter.readingTime && <span>{frontmatter.readingTime} min read</span>}
</header>
<div class="content">
<slot />
</div>
<footer>
<div class="categories">
{frontmatter.categories?.map((cat: string) => (
<a href={`/categories/${cat.toLowerCase()}/`}>{cat}</a>
))}
</div>
<div class="tags">
{frontmatter.tags?.map((tag: string) => (
<a href={`/tags/${tag.toLowerCase()}/`}>#{tag}</a>
))}
</div>
</footer>
</article>
</body>
</html>
Step 7: SEO Preservation
Sitemap
Install the sitemap integration:
npx astro add sitemap
Configure in astro.config.mjs:
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://www.knowledgexchange.xyz',
integrations: [sitemap()],
});
RSS Feed
npm install @astrojs/rss
Create src/pages/rss.xml.js:
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('posts');
return rss({
title: 'KnowledgeXchange',
description: 'Technology articles and tutorials',
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
pubDate: new Date(post.data.date),
description: post.data.description || '',
link: `/post/${post.data.slug}/`,
})),
});
}
Structured Data (JSON-LD)
Add structured data to your post layout for rich search results:
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
"headline": frontmatter.title,
"datePublished": frontmatter.date,
"dateModified": frontmatter.lastModified || frontmatter.date,
"author": {
"@type": "Person",
"name": frontmatter.author
},
"publisher": {
"@type": "Organization",
"name": "KnowledgeXchange"
}
})} />
Step 8: Deploy to Cloudflare Pages
Connect Your Repository
- Push your Astro project to a Git repository (GitHub, GitLab)
- Log in to the Cloudflare Dashboard
- Navigate to Workers & Pages > Create Application > Pages
- Connect your Git repository
- Configure build settings:
- Build command:
npm run build - Build output directory:
dist - Node.js version: 20 (set via environment variable
NODE_VERSION=20)
- Build command:
Custom Domain
- In your Cloudflare Pages project, go to Custom Domains
- Add your domain (e.g.,
www.knowledgexchange.xyz) - Cloudflare automatically provisions an SSL certificate and configures DNS
Build Performance
Cloudflare Pages builds are fast. For reference, our KnowledgeXchange migration:
- 507 posts converted to Markdown content collection entries
- 1,427 total pages generated (posts + category pages + tag pages + index pages)
- Build time: 11 seconds on Cloudflare Pages
Compare this to WordPress, where generating cached versions of 507 posts could take minutes, and each uncached page load required database queries.
The KnowledgeXchange Migration: A Case Study
When we migrated KnowledgeXchange.xyz from WordPress to Astro, we faced several real-world challenges:
Challenge 1: Legacy HTML Formatting
Many older WordPress posts contained raw HTML, shortcodes, and plugin-specific markup. Our conversion script handled the common cases, but approximately 15% of posts required manual cleanup — particularly those with complex table layouts, embedded iframes, and WordPress gallery shortcodes.
Lesson learned: Budget time for manual review. Automated conversion gets you 85% of the way there; the remaining 15% needs human attention.
Challenge 2: Image References
Our 507 posts referenced hundreds of images stored in WordPress’s wp-content/uploads directory structure. We chose to keep the same directory structure in the public/ folder to minimize URL changes.
Lesson learned: Maintain the same image URL paths when possible. Changing image URLs means search engines need to re-index them, and any external sites linking to your images will break.
Challenge 3: Multilingual Content
KnowledgeXchange has content in both English and Spanish. We used the lang frontmatter field to categorize posts and generate language-specific index pages.
Lesson learned: Plan your internationalization strategy before migration. Astro’s content collections make it easy to filter by language, but the routing structure needs to be decided upfront.
Challenge 4: Preserving SEO Rankings
We created comprehensive redirect rules for any URLs that changed format. We submitted the new sitemap to Google Search Console immediately after launch and monitored indexing closely for two weeks.
Lesson learned: Submit your sitemap to search engines the same day you launch. Monitor Google Search Console for 404 errors and add redirects as they surface.
Results
After the migration:
- Page load time dropped from 1.2s average to under 100ms
- Lighthouse performance score went from 67 to 99
- Hosting cost dropped from $15/month to $0/month (Cloudflare Pages free tier)
- Security incidents went from occasional brute-force attempts to zero
- Build and deploy takes 30 seconds from git push to live site
Lessons Learned and Best Practices
-
Do not migrate everything at once. Start with a subset of posts, verify the output, then process the full archive.
-
Keep your WordPress site running during migration. Point a subdomain like
old.knowledgexchange.xyzto it as a reference while you verify the Astro version. -
Test redirects exhaustively. Use tools like Screaming Frog or a simple script to crawl your old sitemap and verify every URL resolves correctly on the new site.
-
Preserve your RSS feed URL or redirect it. Subscribers who rely on your feed should not lose access.
-
Set up analytics from day one on the new site so you can compare traffic patterns before and after migration.
-
Use content collections for type safety. Astro’s schema validation catches frontmatter errors at build time, preventing broken pages in production.
-
Commit your migration scripts to the repository. You may need to re-run them if you discover issues weeks later.
Summary
Migrating from WordPress to Astro is a significant undertaking, but the benefits are substantial: faster load times, better security, lower costs, and a modern developer experience. The key is methodical planning, automated content conversion, and careful SEO preservation.
With content collections, Astro gives you type-safe Markdown handling that scales to hundreds or thousands of posts. Combined with Cloudflare Pages for hosting, you get a production-grade blog infrastructure that costs nothing to operate and handles any amount of traffic.
For related WordPress topics, see our guides on deploying WordPress on Ubuntu and WordPress file permissions on Linux.