$ cat how-to-migrate-your-website-from-wordpress-to-astro.md

How to Migrate Your Website from WordPress to Astro

January 5, 2025

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:

  1. Reads the WordPress posts JSON
  2. Converts HTML content to Markdown using Turndown
  3. Generates proper frontmatter for Astro
  4. Creates clean filenames from post slugs
  5. 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);

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:

  1. Check all posts render correctly - Browse through your blog posts in development
  2. Verify images load - Ensure all media files were migrated successfully
  3. Test redirects - Use the old WordPress URLs to confirm they redirect properly
  4. Validate frontmatter - Make sure dates, titles, and excerpts are correct
  5. 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:

  1. Export content using the WordPress REST API
  2. Convert HTML to Markdown with proper frontmatter
  3. Set up comprehensive redirects
  4. Migrate images and media files
  5. Update internal links
  6. 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.

  • Finder
    Finder
  • Jesse Waites
    Jesse Waites
  • Xcode
    Siri
  • Simulator
    Simulator
  • Testflight
    Testflight
  • SF Symbols
    SF Symbols
  • Icon Composer
    Icon Composer
  • Sketch
    Sketch
  • VS Code
    VS Code
  • Postgres
    Postgres
  • Android Studio
    Android Studio
  • Trash
    Trash