Back to all articles
case-study
🌐

Rebuilding My Portfolio with Astro 5

A deep dive into rebuilding my portfolio with Astro 5, covering content collections, server islands, Vue integration, and why I think Astro is the future of web development.

November 20, 2025 6 min read
Share:
CW

Cody Williamson

Senior Software Engineer

Background

This is the sixth iteration of my portfolio site since 2018. Each version has been a chance to try new frameworks and rethink how I present my work. But this rebuild felt different—Astro 5 changed how I think about building websites.

Why Astro?

I’ve used Next.js, Nuxt, and plain Vue/React for various projects. They’re all capable frameworks. But Astro does something I haven’t seen elsewhere: it treats JavaScript as opt-in rather than the default.

Most frameworks ship JavaScript to the client whether you need it or not. Astro ships zero JavaScript by default. You only add it where interactivity is required. For a portfolio site where most pages are just content, this matters. The result is faster page loads and better Core Web Vitals without extra effort.

But what surprised me is how Astro handles the cases where you do need JavaScript. It doesn’t force you into a specific framework—you can use React, Vue, Svelte, Solid, or even mix them in the same project. I chose Vue because I’m most comfortable with it, but the option to grab a React component from somewhere if needed is nice.

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.

Why I Think Astro is the Future

After building with Astro 5, I’m convinced it’s not just for static sites anymore. The combination of server-first rendering, partial hydration, content collections, and server islands creates a development experience that feels modern without unnecessary complexity.

Most frameworks treat the server as a step before the client takes over. Astro treats the server as the default and the client as an enhancement. For most websites—especially content-focused ones—this is the right mental model.

The ability to use any UI framework (or none) removes the lock-in that comes with other meta-frameworks. If Vue stops being the right tool for something, I can swap in React for that component without rewriting the whole site.

I’m planning to use Astro for more projects. It handles the common case (content sites, marketing pages, blogs) exceptionally well, and the escape hatches for interactivity are clean enough that I wouldn’t hesitate to use it for something more complex.

What’s Next

I’m planning to add blog filtering and search, individual project detail pages, and possibly an interactive resume builder. The content collections pattern makes adding new data types straightforward, so extending the site is mostly a matter of defining schemas and building components.

Enjoyed this article? Share it with others!

Share:

Have a project in mind?

Whether you need full-stack development, cloud architecture consulting, or custom solutions—let's talk about how I can help bring your ideas to life.