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:
- Tracks scroll position to determine which card is active
- Shows a progress bar indicating how far through the timeline youâve scrolled
- Alternates cards left and right with staggered animations
- 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!