← Writing

Why I Rebuilt My Portfolio with Astro

My old portfolio was a full React + client router setup for a site that is, in practice, five static sections and a contact form. It shipped more JavaScript than a page like that needs. I rebuilt it with Astro and shipped almost none.

What Astro actually does differently

Astro renders components to HTML at build time by default. Nothing hydrates on the client unless you explicitly opt in with a directive like client:load or client:visible. For a content-first site, that means the homepage ships with effectively zero framework JavaScript.

---
// src/pages/index.astro
const projects = await getProjects();
---

<section>
  {projects.map((project) => (
    <article>
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </article>
  ))}
</section>

Content collections replaced my ad-hoc data files

Before, blog-style content lived in a few hardcoded arrays. Astro’s content collections give that structure schema validation and type safety for free:

// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const blog = defineCollection({
  loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { blog };

Querying and rendering a post is just:

---
import { getCollection, render } from "astro:content";
const posts = await getCollection("blog");
const { Content } = await render(posts[0]);
---
<Content />

The tradeoffs

Nothing is free. Here’s what I gave up and what I gained:

Before (React SPA)After (Astro)
JS shipped on homepage~140kb~2kb
Interactive componentsTrivial everywhereOpt-in, per component
Content authoringHardcoded arraysMarkdown/MDX with schemas
Build outputClient-renderedStatic HTML

The only place I kept client-side JavaScript is small, targeted enhancements — like the copy-to-clipboard button on code blocks in this post — added with a plain <script> tag rather than a framework.

Steps if you’re doing the same migration

  1. Start with static pages first; don’t reach for client:* directives until something genuinely needs interactivity.
  2. Move existing content into collections early — retrofitting schemas onto loose data later is more work than doing it up front.
  3. Keep global styles in one stylesheet so layouts share the same design tokens instead of duplicating CSS variables per page.

The best performance optimization is often just shipping less JavaScript, not shipping the same JavaScript faster.

If you’re evaluating Astro for a similar content-heavy site, the official docs cover content collections and the islands architecture in more depth than I have here.