8 min read

Migrating my WordPress blog to Astro

A landscape image illustrating the migration of a blog from WordPress to Astro.
Table of Contents

I’ve been meaning to move my blog away from WordPress for a while, perhaps as an excuse to learn more code and also to have a new single place to store all my online ramblings (WordPress, Tumblr, TypePad, etc.) and start writing online again.

Why Astro?

I tried Hugo in the past but eventually got tired of it. Astro is the new shiny thing, gives me something to learn, and seems to be moving in the right direction with content management. I feel like it can be useful not only for my blog, but also for other more elaborate projects I’m working on.

Useful posts

How I Migrated from WordPress to Astro JS — PatrickThurmond.com

Astro Blog Course - YouTube

Plugins or Utilities

While I could’ve used Astro’s extensive API to simply access my WordPress posts and republish them here, I wanted to have a local copy of all my content —in case the original goes away. I have tried the API, and it works quite well, but this approach gives me more flexibility.

I used the following script to convert a WordPress XML export file into markdown posts:

lonekorean/wordpress-export-to-markdown: Converts a WordPress export XML file into Markdown files. (github.com)

I modified the script’s settings.js file to include additional frontmatter that I needed. I also renamed a few frontmatter fields to work better with Astro.

  "id",
  "slug:originalSlug",
  "author",
  "link:originalLink",

and changed:

exports.include_time_with_date = true;

I created a new frontmatter utility: link.js, to extract the original link where the post was hosted:

// get original post link for attribution
module.exports = (post) => {
  return post.data.link[0];
};

This will allow me to point to the original location of each post, as well as segment them by platform. Eventually, I might style each origin platform differently.

Organization

All imported posts will go under src/content/blog/YEAR/post-slug.md

All images will me moved into src/assets/images/ to allow Astro to optimize their delivery. This will require editing the .md files. For now they are in an images folder inside each year. More on this below.

Modifications to astro-micro theme

I’m using the astro-micro theme, borrowing some things from astro-ink and others.

In src/content/config.ts added the new fields to the blog collection:

const blog = defineCollection({
  type: "content",
  schema: z.object({
    title: z.string(),
    author: z.string(),
    description: z.string().optional(),
    date: z.coerce.date(),
    draft: z.boolean().optional(),
    originalLink: z.string().url(),
    originalSlug: z.string(),
    tags: z.array(z.string()).optional(),
    categories: z.array(z.string()).optional(),
  }),
});

In src/components/PostNavigation.astro added the /blog/ directory to the navigation href, so that cross-post navigation worked properly (otherwise you end up in nested folders). Repeat for prevPost and nextPost:

{
    prevPost?.slug ? (
      <a href={`/blog/${prevPost?.slug}`} >

In src/pages/index.astro I removed the entire “Let’s Connect” section and turned it into its own component src/component/LetsConnect.astro so that I could add a quick contact section to every post. I also added a section with other platforms where I’ve posted on the web.

---
import { SITE, HOME, SOCIALS, WEBS } from "@consts";
import Link from "./Link.astro";
---

<section class="animate space-y-4">
  <h2 class="font-semibold text-black dark:text-white">Let's Connect</h2>
  <article>
    <p>
      If you want to get in touch with me about something or just to say hi,
      reach out on social media or send me an email.
    </p>
  </article>
  <ul class="not-prose flex flex-wrap gap-2">
    {
      SOCIALS.map((SOCIAL) => (
        <li class="flex gap-x-2 text-nowrap">
          <Link
            href={SOCIAL.HREF}
            external
            aria-label={`${SITE.TITLE} on ${SOCIAL.NAME}`}
          >
            {SOCIAL.NAME}
          </Link>
          {"/"}
        </li>
      ))
    }
    📧
  </ul>
  <h2 class="pt-12 font-semibold text-black dark:text-white">Other blogs</h2>
  <article>
    <p>
      Some other blogs where I've posted throughout the years. Most of these
      will eventually migrate here.
    </p>
  </article>
  <ul class="not-prose flex flex-wrap gap-2">
    {
      WEBS.map((WEBS) => (
        <li class="flex gap-x-2 text-nowrap">
          <Link
            href={WEBS.HREF}
            external
            aria-label={`${SITE.TITLE} on ${WEBS.NAME}`}
          >
            {WEBS.NAME}
          </Link>
          {"/"}
        </li>
      ))
    }
    🕸️
  </ul>
</section>

I added a new section to the consts.js file to include these web properties:

export const WEBS: Socials = [
  {
    NAME: "Typepad",
    HREF: "https://carlos.typepad.com/anonymonk/",
  },
  {
    NAME: "Red66",
    HREF: "https://red66.com/",
  },
  {
    NAME: "Tumblr",
    HREF: "https://www.tumblr.com/cgranier",
  },
];

Then simply import the component into each page with:

import LetsConnect from "@components/LetsConnect.astro";

and add it where you want it to appear with:

<LetsConnect />

To display this sections on each blog post just import and add the component to your src/pages/blog/[...slug].astro file.

I also added the following sections to each blog post, to display the author and the original location of each post (at my other blog).

In the frontmatter, get the original domain name where the post resided:

// Grab domain name from original link URL for readability
let domain = new URL(post.data.originalLink).hostname;

Then, under the post title, add the original link (only if it exists in the frontmatter metadata):

      {
        post.data.originalLink && (
          <div class="animate flex items-center gap-1.5">
            <div class="font-base text-sm">
              Originally published in:{" "}
              <a href={post.data.originalLink}>{domain}</a>
            </div>
          </div>
        )
      }

And add the author of the post:

      {
        (
          <div class="animate flex items-center gap-1.5">
            <div class="font-base text-sm">
              By: <a href={post.data.author}>{post.data.author}</a>
            </div>
          </div>
        )
      }

A lot of the external images come from Skitch, which Evernote decided to break after acquiring them.

Because of how the convert script works, it attempts to download the image files but ends up downloading an html page from Evernote. So I also needed to clean up these images one by one.

There’s two approaches to fix this:

  1. Create a placeholder image to indicate something was there at some point, or
  2. Visit the Wayback Machine for each post and see if a copy of the image exists. Download that copy. In some cases, even the Wayback Machine doesn’t have a copy. Thanks Evernote.

Other image issues come from images with spaces in their names. These are few enough to fix by hand.

Astro-micro has a problem with its Table of Contents feature: if your article only has H3 headings, it crashes. Moving them up to H2 fixes this (and it also fixes the original articles, which should have H2 subheadings before going into H3).

So, I’ve had to edit these articles one by one, to change the ### to ##.

Using global find-and-replace I fixed all the extended characters (á,é,í,ó,ú,ä,ñ,à,’,”,…) that were sprinkled through out the documents as (ñ,ä,é,ú,ó,á,€¦,€,€œ,Ââ,€™) (not in order).

Upon building the project and seeing a couple of errors, I renamed excerpt to description so I wouldn’t break too many things.

I also edited src/pages/blog/[...slug].astro so that it wouldn’t break if a post’s description was empty:

description={post.data.description ?? ""}

Organizing Images

I figured that since I’m doing all this work already, I might as well take advantage of Astro’s comprehensive image support.

By placing all the images under the src/assets/images/ directory and replacing the img tags throughout the markdown files from (images/image-name.png) to (../../../assets/images/image-name.png) with a simple VS-Code global find-and-replace, Astro will now find all the image files and automatically optimize them for delivery.

Among other things, Astro will infer the image size and add lazy-loading to each image, among other nice optimizations. You don’t even have to import Image components or change anything else. Support for images in markdown files works pretty much out of the box.

Additionally, to make sure I had all the images, I FTP’d all the image files from WordPress and ran these two scripts to clean them up and place them under one directory:

Traverse directories, identify the thumbnail images and their corresponding original images, and perform either a dry run or a hot run to delete the thumbnails (github.com)

Traverse directories and move all image files (jpg, png, gif) to an “images” directory under the current location (github.com)

Publishing

The whole website now weighs about 12MB, and its all static files, which means I can host it pretty much anywhere. Since I want to take advantage of git backup and automatic builds, I will pick a solution that leverages GitHub.

Kinsta is offering free hosting for static websites up to 1GB in size and since I already had a magnificent experience with them while hosting a high-traffic WordPress website, I will give them a try.

Deploying to Kinsta was really easy, after I managed to track down some needed fixes.

  1. Create a Kinsta account.
  2. Connect your GitHub and give Kinsta permission to access your repo.
  3. Publish.

Kinsta has an issue with Astro’s Sharp service, which is used for optimizing images. Many Google searches later, I came upon this bit of Astro documentation: Images | Docs (astro.build) that suggests adding the following to your astro.config.mjs file:

export default defineConfig({
	image: {
		service: {
			entrypoint: 'astro/assets/services/noop'
		}
	}
});

This eventually allowed Kinsta to build the site and publish it.

Let's Connect

If you want to hire me or get in touch about something or just to say hi, reach out on social media or send me an email.

Other blogs

Some other blogs where I've posted throughout the years. Most of these will eventually migrate here.