all writing

Rebuilding My Portfolio with Astro 5

Notes on rebuilding this site with Astro 5 — content collections, server islands, Vue islands, and where it hurt.

5 min read

AI-generated web layout illustration of a browser window with simple layout blocks, in pink on a dark charcoal background
On this page

Background

Sixth version of this site since 2018. Each rebuild has been a chance to try a different stack — this one’s Astro 5 with Vue islands.

Why Astro

I’ve used Next, Nuxt, and plain Vue/React. Astro’s pitch that stuck with me: JavaScript is opt-in, not the default. Most frameworks ship JS whether you need it or not; Astro ships zero and you add it where interactivity lives. For a mostly-content site, that lines up with what I actually need.

The other thing is framework flexibility. You can use Vue, React, Svelte, Solid, or mix them. I’m using Vue because that’s what I’m fast in, but I like that dropping in a React component later isn’t a rewrite.

Astro’s Architecture

Server-First by Default

Astro 5 runs in output: 'server' mode on this site. Every page is server-rendered by default. This isn’t SSR in the traditional SPA sense where you’re hydrating a full app on the client—Astro pages are actual HTML documents that work without JavaScript.

// astro.config.mjs
export default defineConfig({
  site: 'https://codywilliamson.com',
  output: 'server',
  adapter: netlify({
    edgeMiddleware: true,
    cacheOnDemandPages: true
  }),
})

The Netlify adapter handles deployment. Edge middleware runs geographically close to users, and on-demand page caching means frequently visited pages are fast without pre-building everything.

Content Collections

This is where Astro really shines for content-heavy sites. Content collections give you type-safe data fetching with Zod schemas. Every blog post, project, work experience entry, and even navigation items on this site are defined as collections.

Here’s how the blog collection is set up:

const blogCollection = defineCollection({
    loader: glob({ pattern: '**/*.mdx', base: './src/data/blogs' }),
    schema: z.object({
        title: z.string(),
        publishedAt: z.date(),
        description: z.string(),
        excerpt: z.string(),
        tags: z.array(z.string()),
        status: z.enum(['draft', 'published']).default('published'),
        featured: z.boolean().default(false),
        emoji: z.string().optional(),
        // ... more fields
    })
})

The glob loader watches a directory for MDX files. Zod validates the frontmatter at build time, so if I forget a required field or mistype a date, I get an error before deploying.

For JSON data like work experience, the pattern is similar:

const workCollection = defineCollection({
    loader: glob({ pattern: "**/*.json", base: "./src/data/work" }),
    schema: z.object({
        company: z.string(),
        position: z.string(),
        startDate: z.string(),
        endDate: z.string(),
        technologies: z.array(z.string()),
        type: z.enum(['Full-time', 'Part-time', 'Contract', 'Internship', 'Freelance'])
    })
})

Fetching data is straightforward:

import { getEntry, getCollection } from "astro:content";

// Get a single entry
const authorData = await getEntry("site-author", "author");

// Get all entries in a collection
const timelineItems = await getCollection("timeline");

The type inference carries through automatically. If I try to access a property that doesn’t exist on the schema, TypeScript catches it.

Server Islands

Astro 5 introduced server islands, which let you defer rendering of specific components. On the homepage, sections below the fold use server:defer to stream in after the initial page load:

<Experience server:defer>
    <ExperienceSkeleton slot="fallback" />
</Experience>

<Portfolio server:defer>
    <PortfolioSkeleton slot="fallback" />
</Portfolio>

The main content loads immediately, and the deferred sections show skeleton placeholders until they’re ready. This keeps the initial page fast while still server-rendering everything.

Client-Side Interactivity with Vue

For components that need interactivity, Astro lets you hydrate them on the client. The client:load directive tells Astro to ship the JavaScript for that component and hydrate it immediately:

<Hero
    client:load
    name={author.name}
    title={author.title}
    summary={author.summary}
/>

The Hero component is a Vue SFC that handles animations and interactions. Without client:load, it would render as static HTML—which is the default for everything in Astro.

The Timeline Component

The timeline on the About section is the most complex piece of interactivity on the site. It’s a Vue component that:

  1. Tracks scroll position to determine which card is active
  2. Shows a progress bar indicating how far through the timeline you’ve scrolled
  3. Alternates cards left and right with staggered animations
  4. Provides navigation buttons that appear contextually

Here’s the core scroll tracking logic:

const handleScroll = () => {
    if (!timelineRef.value || !props.isExpanded) return;

    const cards = timelineRef.value.querySelectorAll('.timeline-snap-item');
    const viewportCenter = window.innerHeight / 2;
    const timelineRect = timelineRef.value.getBoundingClientRect();

    // Calculate scroll progress
    const scrollableHeight = timelineRect.height - window.innerHeight;
    const scrolled = Math.abs(Math.min(0, timelineRect.top));
    scrollProgress.value = scrollableHeight > 0 
        ? Math.max(0, Math.min((scrolled / scrollableHeight) * 100, 100)) 
        : 0;

    // Determine which card is centered
    cards.forEach((card, index) => {
        const rect = card.getBoundingClientRect();
        const cardCenter = rect.top + rect.height / 2;

        if (Math.abs(cardCenter - viewportCenter) < rect.height / 2) {
            activeCardIndex.value = index;
        }
    });
};

Each timeline entry is stored as a JSON file in a content collection:

{
    "year": "2019",
    "title": "First Career Role On NASA Work",
    "description": "Joined Sure Secure Solutions and worked as a subcontractor on a NASA contract via Leidos...",
    "icon": "rocket",
    "theme": "purple",
    "sortOrder": 6
}

The icon field maps to Lucide icons, and theme controls the color scheme for that card. The component dynamically resolves the icon name to the actual component:

const getIcon = (iconName?: string) => {
    if (!iconName) return LucideIcons.Circle;

    const pascalCase = iconName
        .split('-')
        .map(word => word.charAt(0).toUpperCase() + word.slice(1))
        .join('');

    return (LucideIcons as any)[pascalCase] || LucideIcons.Circle;
};

View Transitions

Astro’s view transitions API handles smooth page navigation. When you click a link, instead of a hard page refresh, the browser transitions smoothly between pages:

<main transition:animate={slide({ duration: "0.3s" })}>
    <slot />
</main>

The navbar persists across transitions with transition:persist, so it doesn’t re-render on every navigation. This gives the site an SPA-like feel without the SPA complexity.

Colors and Theming

The site uses a Dracula-inspired color palette with both light (Alucard) and dark variants. Colors are defined as CSS custom properties and integrated with Tailwind CSS 4:

:root {
    --primary: 265 89% 78%;
    --background: 231 15% 18%;
    --foreground: 60 30% 96%;
    /* ... */
}

Tailwind’s utilities then reference these variables: bg-primary, text-foreground, etc. Switching between light and dark mode is just toggling a class on the HTML element.

Closing Notes

A few things I’d flag for anyone considering the stack:

  • Server islands are genuinely useful, but debugging them when a skeleton never swaps in is a rough edge. Usually it’s a caching header on my end.
  • Content collections + Zod catch more frontmatter typos than you’d think. Worth the schema overhead.
  • transition:persist on the navbar was the single easiest win for perceived smoothness.

What’s next: blog filtering, project detail pages, and probably replacing the Netlify adapter with Cloudflare (which I did shortly after).

written by

Cody Williamson

Senior Software Engineer