From Gatsby to Astro | What actually broke when I rewrote this blog
8 min readweb-dev
I rewrote this blog from Gatsby v2 to Astro 7 — keeping every URL, dropping 1676 dependencies, and shipping one bug straight to production. Here are the parts the migration guides don't mention.
This site had been running on a fork of gatsby-starter-lumen since 2017. It worked. That was
roughly the nicest thing left to say about it — Gatsby v2, Node 12 in CI, Flow for type checking, and
a dependency tree I stopped being willing to touch some time around 2021.
So I rewrote it in Astro. This post is not a tutorial; there are plenty of those, and a few of them are already wrong (more on that below). It’s the list of things that only show up when you migrate a real site that real people have already linked to.
Why Astro, and why not just upgrade
Upgrading Gatsby v2 → v5 was possible. But the thing I actually wanted was less machinery: this is 12 markdown files and some CSS, and it was being rendered by a GraphQL data layer, a React runtime, and a plugin for everything. Gatsby has also been notably quiet since the Netlify acquisition, and I didn’t want to spend the upgrade budget only to be in the same position later.
Astro fit the shape of the problem — markdown-first, zero JS shipped by default, no data layer to learn. I considered Next.js, which has the bigger ecosystem, but for a blog that’s mostly weight I’d never use.
Here’s the diff that matters:
| Gatsby v2 | Astro 7 | |
|---|---|---|
| Direct dependencies | 83 | 16 |
| Total installed | 2185 | 509 |
| Type checking | Flow | TypeScript |
| Build | never benchmarked | 59 pages, ~0.5s |
I never benchmarked the old build before deleting it, so I won’t pretend to a speedup number. The dependency count I can stand behind.
Keeping every URL
This was the non-negotiable part. The posts are indexed, and some of them get steady search traffic.
The catch: my filenames are date-prefixed and don’t always match the post. 2016-02-02---A-Brief-History-of-Typography.md
actually contains a post about neural networks in JavaScript — an artefact of copying a starter’s
example file and never renaming it. The real URL always came from the frontmatter slug.
Astro’s default routing wants to use post.id, which is the filename. So the important line is
routing on the frontmatter instead:
// src/pages/posts/[slug].astro
export async function getStaticPaths() {
const posts = await getPosts();
// Route on the frontmatter slug, not the filename — this is what keeps the
// existing /posts/<slug> URLs byte-identical to the Gatsby build.
return posts.map((post) => ({
params: { slug: post.data.slug },
props: { post },
}));
}
I also didn’t want to move the markdown into src/content/, because that would rewrite the git
history of every post. Astro 5+ lets a collection load from anywhere via the glob() loader:
// src/content.config.ts
const posts = defineCollection({
loader: glob({ base: './content/posts', pattern: '**/*.md' }),
schema: z.object({
template: z.literal('post'),
title: z.string(),
slug: z.string(),
date: z.coerce.date(),
draft: z.boolean().default(false),
// ...
}),
});
The zod schema turned out to be the quiet win. Gatsby happily built pages from malformed frontmatter
and let you discover the problem in production. Now a missing date fails the build.
The markdown pipeline is not the one in the guides
This is the part that cost me the most time, and it’s the reason I’d tell anyone migrating to check the installed version rather than trusting a blog post — including this one.
Astro 7 replaced remark/rehype as the default Markdown processor. The new default is called Sätteri, Astro’s native pipeline. It’s faster, it handles GFM and smart punctuation natively, and it has its own plugin system based on mdast/hast plugins. Standard remark and rehype packages are not compatible with it.
Which means every “add KaTeX to Astro” article you’ll find is now subtly wrong. One of my posts uses
math, so remark-math and rehype-katex were non-negotiable. The fix is to explicitly opt back into
the unified processor:
// astro.config.mjs
import { unified } from '@astrojs/markdown-remark';
export default defineConfig({
markdown: {
// Astro 7 defaults to the Sätteri processor, which has its own plugin
// system and no math support. Opt into unified so the remark/rehype
// plugins below (notably KaTeX) work.
processor: unified({
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeDemoteHeadings, rehypeAutolinkHeadings, rehypeKatex],
}),
},
});
Note this is not the same as the top-level markdown.remarkPlugins option, which still exists but
is deprecated. Pass plugins to the processor.
My posts had been using the wrong heading level for nine years
Rendering the first post revealed something I’d never noticed: I write section headings as #.
Every one of them. So each post rendered with a page title in <h1> and then six more <h1>s in the
body.
Gatsby had been doing this too — I’d simply never looked. Options were to rewrite the markdown in every post, or to fix it at render time. I picked render time, because the markdown is the archive and I’d rather not touch 12 files for a presentation concern:
// src/lib/rehype-demote-headings.mjs
const DEMOTED = { h1: 'h2', h2: 'h3', h3: 'h4', h4: 'h5', h5: 'h6', h6: 'h6' };
export default function rehypeDemoteHeadings() {
return (tree) => {
const walk = (node) => {
if (node.type === 'element' && DEMOTED[node.tagName]) {
node.tagName = DEMOTED[node.tagName];
}
if (node.children) node.children.forEach(walk);
};
walk(tree);
};
}
Twelve lines, no dependency — it’s just a tree walk over hast. This is the part of the unified ecosystem I actually like: when you need something oddly specific, it’s a function.
One follow-on: the heading ids come from Astro’s built-in rehypeHeadingIds, which also populates
the headings array you use to build a table of contents. Don’t add rehype-slug on top — you’ll
get two competing id schemes and a TOC whose anchors don’t match the headings. And since the depths
shift, the TOC component normalises against the shallowest heading in the document rather than
hardcoding h2/h3.
The bug I shipped to production
Search is Pagefind, which indexes the built site after Astro finishes:
"build": "astro build && pagefind --site dist"
I wrote the loader to pull the UI in dynamically, so that the search page degrades gracefully in dev where no index exists:
// don't do this
const { PagefindUI } = await import(`${base}pagefind/pagefind-ui.js`);
This is wrong, and it’s wrong in the most annoying way possible: it fails silently. pagefind-ui.js
is an IIFE that assigns window.PagefindUI. It exports nothing. The dynamic import fails at
module-link time, my catch block swallowed it, and the page displayed the “search is unavailable in
dev” fallback — on production.
It has to be loaded as a classic script:
await loadScript(`${base}pagefind/pagefind-ui.js`);
const PagefindUI = window.PagefindUI;
if (!PagefindUI) throw new Error('PagefindUI global missing');
new PagefindUI({ element: '#search', showSubResults: true });
The real lesson isn’t about Pagefind. I had verified that the build generated the index — 13 pages indexed, right there in the build log — and treated that as confirmation that search worked. It isn’t the same claim. The build succeeds and exits 0 whether or not the page can load the thing it built. CI wouldn’t have caught it either; only opening the page would, and I didn’t until someone told me it was broken.
The dev server lies about your config
Smaller, but it cost me twenty minutes of genuine confusion. astro dev does not pick up changes to
astro.config.mjs. It keeps serving the old pipeline and gives no indication that it’s doing so.
I added the heading-demotion plugin, reloaded, and saw <h1>s still in the DOM — so I went looking
for a bug in the plugin. There wasn’t one. The built output was already correct; the dev server was
stale. After editing the config:
npx astro dev stop && npx astro dev
What I gave up
Being honest about the costs:
- Netlify CMS. I used to write posts in a browser admin panel. Decap CMS (the renamed successor) would work fine with Astro, but I write locally anyway, so I dropped it. Losing the ability to publish from my phone is a real, if small, loss.
- Jest snapshot tests. The starter shipped component snapshots. They tested the theme’s
components, none of which exist any more.
And one thing I gave up by mistake and had to put back: Google Analytics. My tag id starts with
UA-, so I assumed it had been dead since Universal Analytics stopped collecting in July 2023, and dropped it. It hadn’t been — Google’s 2023 auto-migration connected legacy UA tags to the GA4 properties it generated, so the old tag was still reporting into a live dashboard. I only found out because I went looking at the dashboard. Check before you assume, particularly for the things that fail quietly.
Was it worth it
For a content site, yes. The thing that convinced me wasn’t the build time — it’s that I can now read the entire site’s source in one sitting. 16 direct dependencies, no data layer, no GraphQL, and markdown files that are still just markdown files, in the same paths, with the same git history, at the same URLs.
If you’re doing the same migration, the two things I’d actually plan for: check which markdown processor your Astro version defaults to before you copy any plugin config, and open every page of the built site in a browser before you call it done. The build exiting 0 means less than it looks like.