Building a Scalable Portfolio with Astro
Building a Scalable Portfolio with Astro
When I decided to rebuild my personal portfolio, I had a classic “modern web” dilemma. I wanted the speed of a static site (no database queries, no server wait times), the interactivity of a Single Page Application (SPA) for certain components, and the SEO benefits of server-rendered content. Oh, and I didn’t want to spend my weekend wrestling with Webpack configs.
Enter Astro
After three months in production, my portfolio scores a almost perfect 90’s on Lighthouse, loads in under 300ms on 4G, and has room to scale to thousands of pages. Here is exactly how I built it.
The “Islands” Architecture: Why Astro Wins
Traditional frameworks (React, Vue, Svelte) send entire JavaScript bundles to the client. If you have a static header and a non-interactive blog post, React still ships its runtime.
Astro flips the script with Islands Architecture. The page is static HTML (zero JavaScript). Interactive components (a dark mode toggle, a canvas animation, a search bar) are “islands” hydrated individually.
Result: If you disable JavaScript on this blog post, it still renders perfectly. The menu just won’t open. That is performance by default.
Step 1: Laying the Foundation
I started with the strictest setup possible.
npm create astro@latest -- --template basics --typescript strict --git yes
I chose strict TypeScript and the basics template. No bloat. Folder structure for scale:
src/
├── components/ # (Button.astro, Card.astro, SEO.astro)
├── layouts/ # (BaseLayout.astro, ProjectLayout.astro)
├── pages/ # (index.astro, projects/[slug].astro)
├── content/ # (projects/, blog/) <- Astro Content Collections
└── styles/ # (global.css)
Step 2: Content Collections for Future-Proofing
For a portfolio to scale, your content cannot be hardcoded in JSX. Astro’s Content Collections changed my life. Instead of React components importing markdown files, I defined a schema. src/content/config.ts:
import { defineCollection, z } from 'astro:content';
const projects = defineCollection({
schema: z.object({
title: z.string(),
pubDate: z.date(),
tech: z.array(z.string()),
featured: z.boolean().default(false),
liveUrl: z.string().url().optional(),
}),
});
export const collections = { projects };
Now, when I add a new project markdown file, Astro validates the frontmatter automatically. TypeScript gives me autocomplete for entry.data.title. Zero runtime errors.
Step 3: The “Hybrid” Rendering Trick
My portfolio has two types of pages:
- Static: Homepage, About, Contact. (Built at deploy time)
- Dynamic: Individual project pages. (Scalable, but still static) By default, Astro is static. But what about a “Recent Views” counter? That needs a server. I enabled Hybrid mode in astro.config.mjs:
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'hybrid', // SSR for some, static for others
});
Now, my “Contact” form uses an Astro endpoint (/api/contact.ts) that runs server-side. The blog remains static. Best of both worlds without paying for a backend.
Step 4: Optimizing Images for Scale
Nothing kills a portfolio’s speed like unoptimized 5MB PNGs.
Astro’s built-in <Image /> component is non-negotiable.
Before (bad):
<img src="/screenshot.png" />
After (good):
---
import { Image } from '@astrojs/image/components';
---
<Image
src={entry.data.heroImage}
alt="Project dashboard"
widths={[600, 1200, 1800]}
sizes="(max-width: 800px) 100vw, 800px"
/>
This generates multiple resolutions, lazy loads, and outputs WebP automatically. My image weight dropped from 4.2MB to 89KB average.
Step 5: The Scalability Test (100 Projects)
To test scale, I generated 100 dummy project pages using a Node script that created markdown files. The build time comparison:
- Gatsby (GraphQL): 4m 12s
- Next.js (Static Export): 2m 18s
- Astro: 11 seconds Why? Astro doesn’t bootstrap a virtual DOM during build. It renders straight to strings. Pro Tips I Learned the Hard Way
- Use View Transitions for free Astro 3.0+ includes built-in view transitions. Add it to your layout:
---
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
</html>
Now page navigations are SPA-fast, but you still get static HTML. It feels magic.
-
Deploy to Github Pages You can see the tutorial on how to deploy to Github Pages.
-
Don’t over-island Only add client:load to components that truly need JavaScript on initial paint. Use client:visible for fold components or client:idle for non-critical widgets. The Final Verdict Six months ago, my React portfolio shipped 247kB of JavaScript to render a static bio and three images.
My Astro portfolio ships 8.7kB (mostly analytics and a dark mode toggle).
The developer experience is unmatched: · Bring your own animation or use Vanta JS · No hydration mismatches because the server and client use the same component syntax. · Scales to thousands of pages without slowing down your build pipeline.