Most of my day job lives in heavy frameworks. A portfolio doesn’t need one — so for once, I let the problem pick the tool instead of reaching for the one I already knew.
Why Astro
A portfolio is mostly static: a few pages, a list of projects, a blog. There’s almost no state to manage and nothing that needs to re-render as you click around. The reactivity React or Angular give you would be solving a problem I didn’t have — and it would ship a runtime to every visitor for no reason.
So I picked Astro. It renders to static HTML by default and ships zero JavaScript unless I explicitly ask for it. For a site that’s mostly text and links, that’s exactly the right default.
There was a second reason, and it mattered just as much: I knew I’d be writing posts like this one. I didn’t want to hand-write HTML, or babysit a file full of indentation and manual line breaks. Astro renders Markdown out of the box — the post you’re reading is just a file with some frontmatter on top:
---
title: 'Static by default, dynamic where it counts'
pubDate: 2026-06-21
tags: ['astro', 'preact', 'pagefind']
---
I write prose. Astro turns it into the page. That's the whole
authoring experience I was after.
The one place static wasn’t enough
Once the site was up, I wanted a search box for the blog. And that’s where “mostly static” ran out. Search is logic that runs in the browser — reacting to keystrokes, filtering, ranking results — and Astro, by design, doesn’t hand you that at runtime.
I could have written it in vanilla JavaScript. I didn’t want to; I find vanilla JS genuinely unpleasant to maintain the moment state enters the picture.
My first instinct was React, since it’s a familiar model, but even that felt like shipping too much runtime for a single input field. So I reached for Preact. It gives me the exact same component model and developer experience, but in a 3kB package. Simple and tiny was the whole point.
This is where Astro quietly earns its keep. It’s framework-agnostic, and it ships JavaScript per island — I mark the one interactive component, and everything around it stays static HTML:
---
import SearchPalette from '../components/SearchPalette.tsx';
---
<!-- The only JavaScript the blog index ships. Everything else is HTML. -->
<SearchPalette client:load />
That client:load is the whole contract: this component, and only
this component, hydrates in the browser.
A second island: the typewriter
The search box isn’t the only island. Open the homepage and the stack list types itself out one word at a time, then backspaces and moves to the next. It’s a tiny flourish — and a perfect example of what a Preact island is for.
I’ll spare you the implementation — it’s a small hook and a few lines
of JSX. The part worth showing isn’t the code anyway. Because this
post is an .mdx file, I’m not stuck with a screenshot of the site:
I can import the real component and let it run inside the article. So
here is that same homepage island, dropped straight into the page:
$ stack = []
It hydrates under the same client:load contract as the search box:
one component comes alive, the prose around it stays static HTML.
Writing and running code in the same file is the part I find
genuinely fun about building on Astro.
Search without a backend
A search box still needs something to search. The usual answer is a backend: an index, a database, an API sitting in front of it. For a static portfolio, that’s absurd — I’m not standing up infrastructure and a database just to find text across a handful of posts.
So I used Pagefind. It’s written in Rust, and instead of a server it builds a static search index at build time, then runs the search in the browser through WebAssembly. It hooks into the build with one extra step:
// package.json
"scripts": {
"build": "astro build && pagefind --site dist"
}
Pagefind crawls the built dist/ folder and writes a search index
plus a WASM bundle next to the rest of the site. I tell it what
counts as content with a single attribute on the article:
<article data-pagefind-body>
<Content />
</article>
At runtime the Preact island loads that bundle on demand and asks it for matches — no server in the loop:
// The index is generated at build time; absent during dev.
const pagefind = await import('/pagefind/pagefind.js');
const { results } = await pagefind.search(query);
No database, no API to keep alive — the search runs on the exact same static hosting as everything else, and it stays fast because the heavy lifting is compiled. It’s also the second time Astro’s framework-agnosticism paid off: Preact for the UI, Pagefind’s WASM underneath, both living happily inside a site that’s static everywhere else.
The infrastructure
Because the site is just static files, the hosting stays simple. An S3 bucket holds the build output, CloudFront sits in front of it as the CDN and terminates TLS, and the bucket itself stays private — only CloudFront can read it. The domain came from Squarespace, so I moved it to Route 53 by repointing its nameservers; now DNS lives with the rest of the stack, and an alias record points the apex straight at the distribution:
resource "aws_s3_bucket" "site" {
bucket = var.domain_name
}
resource "aws_route53_record" "apex_a" {
zone_id = aws_route53_zone.site.zone_id
name = var.domain_name
type = "A"
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
}
ACM issues the certificate CloudFront serves, and all of it is Terraform. I didn’t want to click through a console and forget what I’d done a month later — I wanted the infrastructure described in code: reviewable, reproducible, versioned alongside the site itself.
I used to call that “cloud-agnostic.” It isn’t, really — these resources are AWS to the core. If this ever moves off AWS the code changes, but the workflow — reviewable, reproducible infrastructure as code — stays exactly the same. That’s the real value of a provider-agnostic tool like Terraform: not portable infrastructure, but a portable way of managing it.
There’s one honest exception, and it’s this whole section. AWS isn’t the smallest option for static hosting — Netlify or Cloudflare Pages would have been simpler. I chose it for the granular control, and because it’s the stack I know best and enjoy most. That’s the other side of right-sizing: sometimes the right tool isn’t the one with the fewest moving parts, but the one you’ll actually operate well and maintain with care.
Where it stands
Almost every layer of this site is the smallest tool that solved the actual problem. Astro because a portfolio is static. A couple of Preact islands for the pieces that aren’t — a search box, a typewriter. Pagefind because search didn’t need a server. Terraform because infrastructure shouldn’t be a memory of buttons I once clicked.
None of these were chosen for being the most powerful option on the table — or the simplest one. They were the right-sized one. The best engineering decision is often the smallest thing that works — reaching for complexity only where the problem actually demands it, and being honest about where it doesn’t.