5 min read

My Blog Posts Were Returning 500s, So I Rewrote the Whole Thing in Astro

My blog at jnye.co was built on Next.js (App Router) with Directus as the headless CMS, backed by MSSQL on Azure. It worked — until one day it didn’t.

Posts started returning 500 errors due to an issue with Directus. I could have investigated and fixed it, but this was the latest in a pattern of small issues that kept creeping up. The Directus self-hosted instance needed babysitting. The Next.js build had to throttle concurrency to avoid overwhelming Directus during static generation — there was literally workerThreads: false, cpus: 1 in the Next.js config to stop it hammering the CMS. CSP headers had to whitelist directus.jnye.co for images. Every page load depended on a chain: Vercel → Next.js → Directus API → MSSQL.

When the 500s hit, I had a choice: debug the Directus issue, or simplify the whole stack. I chose to simplify.

Twelve months ago, that wouldn’t have been a realistic option. A full framework migration — rewriting pages, moving data, rebuilding API routes, converting components — was a multi-day project you’d procrastinate on indefinitely. You’d debug the Directus issue, patch it, and move on, knowing the underlying architecture was still fragile. The cost of a rewrite was too high relative to the cost of living with the problem.

That calculus has changed. With Claude Code and composable skills, a rewrite is no longer a weekend project — it’s an afternoon. And the follow-up work (architectural cleanup, testing, module consolidation) that used to be the part you’d never get to? That happened in a single conversation.

The old stack

  • Next.js (App Router) on Vercel
  • Directus (self-hosted on Azure) as headless CMS
  • MSSQL on Azure for posts, comments, tags, views
  • Posts fetched at build time via the Directus SDK
  • Comments and views fetched at runtime via API routes
  • OG images generated with satori

The architecture meant posts lived in two places: the source of truth was MSSQL (via Directus), and Next.js rebuilt static pages from it. This indirection was the root cause of fragility — if Directus was down or slow, builds failed and pages 500’d.

Why Astro

Astro’s model clicked for a blog: static by default, with opt-in server rendering only where needed. Posts could be Markdown files in the repo (Astro content collections). Comments and views could stay in MSSQL via SSR API routes. No CMS layer in between.

The key simplification: posts move from Directus → Markdown files, eliminating the entire CMS dependency. Comments and views stay in MSSQL because they’re genuinely dynamic.

The migration

The whole migration landed in a single commit. 112 files changed — deleting the Next.js app, Directus integration, and server actions, replacing them with Astro pages, components, and content collections.

Export posts from Directus to Markdown

I wrote a one-time migration script (scripts/export-directus.ts) that pulled all posts from Directus, resolved tag relationships, and wrote Markdown files with frontmatter into src/content/posts/. Each file looks like:

---
id: 5
title: "Generic Repository Search Function with Expression Trees"
urlSlug: "generic-repository-search-function-with-expression-trees"
created: 2014-02-05
seoDescription: "..."
shortIdentifier: "5"
tags: ["entity-framework", "generics", "expression-trees", "csharp"]
---

Post body in Markdown...

This was the hardest part — making sure the export was clean, tag relationships resolved correctly, and frontmatter matched the Zod schema I’d defined for Astro’s content collections. Directus stores tag relationships as junction table references (Tags: [{ Tags_Id: 5 }, { Tags_Id: 12 }]), so the export script had to fetch all tags first, build a lookup map, then resolve each post’s tags to their string names.

Replace Next.js pages with Astro pages

Most page components translated directly. The big win: components that were React in Next.js became zero-JS Astro components. Only truly interactive pieces (comment form, hero animation, employment timeline) stayed as React islands hydrated with Astro’s client:load directive.

The App Router’s file conventions (page.tsx, layout.tsx, opengraph-image.tsx) mapped cleanly to Astro’s conventions ([slug].astro, BaseLayout.astro, og/[slug].png.ts).

Rewrite API routes

Next.js Route Handlers (app/api/*/route.ts) became Astro API routes (src/pages/api/*.ts) with export const prerender = false. The MSSQL connection stayed the same — I just moved from Directus’s query layer to direct SQL via mssql. For a blog with 2 tables (Comments, Posts), raw SQL is simpler than a CMS SDK.

Clean up React usage

A follow-up commit reduced React usage further, converting components that didn’t need interactivity from React to Astro. The principle: if it doesn’t need client-side state or event handlers, it should be an Astro component (zero JS shipped).

What happened next: architecture in a conversation

The migration got the site working, but the code still had the shape of a quick rewrite — duplicated patterns, scattered concerns, no tests. Normally this is where technical debt accumulates. You ship the rewrite, feel good about it, and never come back to clean it up.

Instead, I used Matt Pocock’s improve-codebase-architecture skill for Claude Code. It explored the codebase, traced import graphs, identified coupling clusters, and ranked 5 architectural improvements by impact. Then, in a single conversation, we worked through all 5:

  1. Comment submission pipeline — 5 scattered files consolidated into a src/lib/comments/ module with client/server split and 24 boundary tests
  2. API route boilerplate — 3 utility functions (json, parseId, requireAdmin) that simplified all 9 endpoints
  3. Tag handling — inconsistent matching and missing slug helpers centralised into src/lib/tags.ts
  4. OG image generation — asset caching, extracted font sizing logic, response helper
  5. Database discoverability — query files co-located in src/lib/data/

The codebase went from zero tests to 43 tests. Every module got cleaner boundaries. And crucially, this happened the same week as the migration — not “someday when I get around to it.”

This is the real shift. It’s not just that AI makes rewrites faster. It’s that the follow-up work — the architectural improvements you’d normally skip — becomes achievable in the same sitting.

The new stack

  • Astro 5 on Vercel — static pages, SSR only for API routes
  • Markdown files in the repo for posts (content collections with Zod schema)
  • MSSQL on Azure for comments and view counts only
  • No CMS — posts are edited as Markdown, committed to git

What got simpler

BeforeAfter
Next.js + Directus + MSSQLAstro + MSSQL
Posts in database via CMSPosts as Markdown files in repo
Build throttled to 1 CPU to not overwhelm CMSNo external dependency at build time
CSP whitelist for Directus image domainNo external image domain needed
Directus SDK + server actions for data fetchingDirect SQL for comments/views, content collections for posts
Every component ships React JSOnly interactive islands ship JS

What got faster

The reduced page size was immediately noticeable. Astro ships zero JavaScript by default — the only JS on the page is the React islands that genuinely need interactivity (comment form, hero animation). The Next.js version shipped the React runtime to every page whether it needed it or not.

Build times also improved since there’s no external API to call during static generation. Posts are just local Markdown files.

Gotchas

Astro’s client/server boundary is stricter than Next.js. In Next.js App Router, you mark components with "use client" and everything below is client-side. In Astro, React components are islands — they’re explicitly hydrated with directives like client:load. This is actually clearer, but it means you need to be deliberate about what gets hydrated. Importing a server-only module from a client island fails the build immediately (no runtime surprise).

astro:env/server virtual modules can’t be imported in client code. Environment variables that are server-only (like the MSSQL connection string) use Astro’s env module, which Vite will refuse to bundle for the browser. This is a feature, not a bug — but it means you need clean separation between client-safe and server-only code from the start.

The real takeaway

The interesting lesson isn’t “Astro is better than Next.js” — it’s that the economics of technical decisions have changed.

A year ago, the rational choice when Directus broke was to fix it. The migration cost was too high: days of work, risk of regressions, and the certainty that the cleanup work would never happen. So you’d patch the problem and live with the fragile architecture.

Now, with Claude Code and composable skills (like Matt Pocock’s improve-codebase-architecture), the whole journey — migration, architectural cleanup, test coverage — happened in days, not weeks. The rewrite landed in an afternoon. The follow-up refactoring happened in a single conversation.

There’s no reason to put up with legacy or sub-optimal tech decisions anymore. The cost of fixing them has dropped below the cost of living with them. That’s the real shift.