Migrating this blog from Jekyll to Astro

This site has been running on the same setup since 2018: Jekyll for the pages, Laravel Mix wrapping webpack for the assets, 47 SCSS files, Prism highlighting code in the browser and a Google Analytics snippet that - thanks to the Universal Analytics shutdown - had been quietly recording nothing for years.

None of this was broken, exactly. But every time I came back to write something I had to coax a Ruby environment and a webpack 2-era build pipeline back to life first. Maintaining two language runtimes to serve nine markdown files felt like a poor trade, so I rebuilt the site with Astro. Same content, same URLs, considerably less machinery.

Why Astro

A few things made it the obvious choice for a content site like this one:

  • Markdown stays markdown. My posts moved over byte-for-byte; only the frontmatter needed a tidy-up.
  • Content collections give you a typed schema for that frontmatter, so a missing description or a malformed date is a build error rather than a silent gap in the page.
  • Zero JavaScript by default. Astro ships HTML and CSS unless you explicitly opt a component into running client-side. For a blog, almost nothing needs to.
  • Syntax highlighting at build time. More on this below, because it’s my favourite of the lot.

The URL contract

The one non-negotiable was that no existing URL could break. Eight years of inbound links, search results and bookmarks all point at /:title/ style paths, and a redesign is a terrible reason to 404 them.

So before writing any code, I crawled the live site and built an inventory of every URL it served: the posts, /blog/, /about, and the /category/:slug/ pages. That inventory became the acceptance test for the whole migration - if the new build didn’t produce exactly those paths, it didn’t ship.

Doing this surfaced two things I’d never noticed:

  1. Some of my posts had a url: key in their frontmatter that Jekyll had been silently ignoring for years - it’s not a key Jekyll recognises (it wants permalink). The live URLs had been derived from the filenames all along. Had I “faithfully” honoured that frontmatter in the new site, I’d have broken those posts.
  2. My post pages linked to category pages that didn’t exist. Jekyll only built a category page if I’d hand-written a stub file for it, and I’d only done that for eight of the nineteen categories I’d actually used. Eleven category links had been 404ing for years.

The second one fixed itself almost for free, because in Astro the category pages are derived from the posts:

export async function getStaticPaths() {
    const posts = await getCollection('posts');
    const categories = [...new Set(posts.flatMap((post) => post.data.categories))];

    return categories.map((category) => ({
        params: { category: category.toLowerCase() },
        props: {
            posts: posts.filter((post) => post.data.categories.includes(category))
        }
    }));
}

Tag a post with a new category and the page for it appears at the next build. No stub files to forget.

Content collections

The collection schema is the backbone of the new site. It lives in src/content.config.ts:

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const posts = defineCollection({
    loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
    schema: z.object({
        title: z.string(),
        description: z.string(),
        excerpt: z.string(),
        date: z.coerce.date(),
        updated: z.coerce.date().optional(),
        categories: z.array(z.string()).default([]),
        image: z.string().optional()
    })
});

The filename becomes the entry’s id, which I use directly as the URL slug - so the file migrating-this-blog-from-jekyll-to-astro.md is the route /migrating-this-blog-from-jekyll-to-astro/. Jekyll’s filenames carried a date prefix (2018-04-18-...) that fed its permalink logic; the date lives in frontmatter now, so the prefix went and the filenames match the URLs exactly.

While I was in there, the schema caught a genuine inconsistency: one post used a singular category: key instead of categories:. Jekyll shrugged at that; zod refused to build until it was fixed. That’s precisely the behaviour I want from a content pipeline.

Code blocks without the JavaScript

The old site shipped Prism to every visitor and highlighted code in the browser, on every page view, forever. The new site uses Astro’s built-in Shiki integration, which does the same work once, at build time, and ships the result as plain HTML with inline colours:

export default defineConfig({
    site: 'https://chrisboakes.com',
    markdown: {
        shikiConfig: {
            theme: 'github-dark'
        }
    }
});

That’s the entire configuration. No highlighter script, no theme stylesheet, no flash of unhighlighted code. The only client-side JavaScript involved in code blocks now is a tiny copy-to-clipboard button - a progressive enhancement measured in bytes rather than kilobytes.

The design: same bones, less ceremony

I kept the layout - this is a refresh, not a rebrand - but the styling collapsed from 47 SCSS files and a webpack pipeline into one global stylesheet of custom properties plus scoped styles inside each Astro component. Sass was solving problems (variables, nesting, scoping) that CSS and Astro now solve natively.

The custom properties also made dark mode cheap: the colour tokens swap under prefers-color-scheme, a small toggle persists an override to localStorage, and an inline script in the <head> applies the stored choice before first paint so there’s no flash of the wrong theme.

Deploying with GitHub Actions

The site deploys to Netlify, but builds happen in GitHub Actions: pull requests get a build-only CI check, and pushes to main build once and deploy the output with the Netlify CLI.

- name: Deploy to Netlify
  run: npx netlify-cli deploy --prod --no-build --dir dist
  env:
    NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
    NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

One gotcha worth recording: recent versions of the Netlify CLI run your site’s build before deploying by default. My deploy job only downloads the already-built dist/ artifact - there’s no package.json in sight - so the first deploy fell over until I added --no-build. If you’re deploying a prebuilt directory from CI, you almost certainly want that flag.

What I’d tell you to do first

If you’re migrating a site of any age, do the URL inventory before anything else. Crawl your live site, write down every path it serves, and diff your new build output against that list before you ship:

grep -rhoE 'href="/[^"]*"' dist --include='*.html' | sort -u

It took minutes and it caught both of the surprises above - one of which would have broken real links had I trusted the source code over the deployed reality. The repo is the theory; the live site is the practice. Migrate the practice.