Rebuilding My Portfolio with Astro: Why I Left Next.js

· English · Ghostwritten by Claude Sonnet 5

articleastromigrationperformance

My old portfolio was a Next.js app. It worked fine, but the entire site — a static bio, a certifications list, a couple of project showcases — was shipping React’s hydration runtime to every visitor, on every page, whether or not the page actually needed any client-side interactivity. Almost none of it did.

Zero JS by default

Astro’s model flips the default: components render to plain HTML unless you explicitly opt a piece of the page into client-side JavaScript with a client: directive. The only part of this site that actually needs to run in the browser is the AI chat widget, so that’s the only part that ships a JavaScript bundle:

---
import { ChatWidget } from '../components/ai/ChatWidget';
---

<ChatWidget client:idle />

client:idle hydrates once the browser is idle, so it never competes with the initial render for resources. Everything else on the page — the bio, the certification cards, the footer nav — is static HTML, generated at build time, with no runtime cost at all.

Content Collections instead of hand-rolled data files

Certifications, project showcases, and now blog posts (this one included) are all typed content collections, validated against a Zod schema at build time:

const blogSchema = z.object({
  title: z.string().min(1),
  date: z.coerce.date(),
  tags: z.array(z.string()).default([]),
  summary: z.string().min(1),
});

If I forget a required field in a post’s frontmatter, the build fails immediately with a clear error — not a runtime undefined bug three pages deep.

Server logic without a separate API layer

The one genuinely dynamic feature — the AI chatbot — needed a real backend: rate limiting, encryption, a call out to Anthropic’s API. In the old Next.js app that meant a Server Action. In Astro, Actions fill the same role: typed RPC functions that run only on the server, callable from the browser without hand-writing a REST endpoint or exposing any secret to client code.

The migration wasn’t just a framework swap — it was also a chance to fix things I’d wanted to fix for a while (a rate limiter that actually works across multiple instances, a hybrid-encryption implementation that doesn’t depend on a third-party crypto library). I’ve written those up separately.