I kinda learned Astro by overbuilding everything
This web is my first Astro project, with lots of stuff: islands, content collections, four themes I couldn't choose between, the components I borrowed from MagicUI, and the CPU fire I started
This is the first real code post on here, and it’s the most on-brand one I could’ve picked: it’s about the site you’re reading it on. I’d never touched Astro before this. I picked it for a blog, then proceeded to build four of them. Here’s how that happened, what Astro actually taught me along the way, and the part where I set my Ryzen on fire. Well, not exactly, but goddammn, some MagicUI components were too much for any kind of CPU when used multiple times. Anyway… there’s code in here, all of it lifted straight out of this site, so if you want to retrace any of it you can
Why Astro at all
I wanted a blog, not a single-page app pretending to be a blog. Most of what I write here is going to be static: words, code blocks, screenshots, the occasional diagram, etc. The kind of thing that has no need to have a MB of JavaScript just to render some paragraphs. Also, I talk a lot, I’m really used to make some text files writing my notes, my write ups, and it would be nice to have everything here
Astro’s whole thing is “static by default, bring your own framework when you actually need it”, and that sounded right for what I wanted, and also I’ll be honest… I also just wanted to learn something new. So I went in completely fresh, no idea what I was doing, which is the best way to find out
The main thingie I needed to learn about: islands
Coming from React-land, my mental model was “everything is a component, everything renders on the client, everything is alive”, but Astro quietly broke that on day one
In Astro, a component is just static HTML by default. You can drop a full React component into a page and it’ll render to HTML at build time and ship zero JavaScript. None. The interactive bits, the “islands”, only come alive if you explicitly ask them to. The rest of the page is just… HTML, as plain as it gets, and perfect for a static lightweight page
That took a second to sink in. I kept waiting for the catch, for the part where it secretly hydrates everything anyway, like those React re-renders because of any tiny thingie. But it doesn’t, so if you don’t opt a component in, the browser never even knows React was involved. For a blog that’s mostly text, that’s exactly what I needed
Opting in on purpose
So how do you wake up an island? You tell it when. Astro calls these client directives, and the moment they clicked was the moment I realized I get to choose what anything costs:
<Particles client:visible color="#3b82f6" quantity={50} />
That client:visible means the particle background doesn’t load its JavaScript until you actually scroll it into view. The main ones I had to learn about:
client:loadhydrates immediately, for stuff that has to be interactive the instant the page is upclient:idlewaits until the browser’s done with the important work, then quietly hydratesclient:visiblewaits until it scrolls into the viewport, which is perfect for anything below the fold
Suddenly every interactive thing on the page is a choice instead of a default tax. Coming from “everything’s on all the time and pray to not have too many components in a chain” having a thingie to choose what runs, and when, felt almost too generous
The blog is just data
Here’s the bit that sold me on Astro as more than a static-site toy: the posts themselves aren’t pages I hand-wire. They’re a typed collection. I describe the shape of a post’s frontmatter once, in a schema, and Astro validates every markdown file against it at build time:
const blog = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
category: z.enum(BLOG_CATEGORIES),
tags: z.array(z.string()).default([]),
heroImage: z.string().optional(),
draft: z.boolean().default(false),
}),
});
If I typo a category, or forget a description, or write a date that isn’t a date, the build fails and tells me which file. I can even post something with draft: true, which means it’s real to my dev server but invisible to the live build until I toggle it. And because category is an enum tied to my actual category list, a post literally cannot exist in a category I haven’t defined. The whole blog is type-safe content, and I didn’t have to make up a database to get there
Turns out, I don’t even need React for most of it
I had another realization that actually changed how I built the site: a huge chunk of it doesn’t need React at all
Take the About page. It’s got scroll-reveal animations, a bento grid, hover states, the works. But it has zero React islands. It’s plain Astro components, a little CSS, and a single IntersectionObserver to trigger the animations. The reveal is just two classes and a transition:
[data-reveal] {
transition: opacity 0.5s ease, transform 0.5s ease;
}
[data-reveal].reveal-init {
opacity: 0;
transform: translateY(16px);
}
[data-reveal].reveal-init.is-visible {
opacity: 1;
transform: none;
}
const els = document.querySelectorAll("[data-reveal]");
els.forEach((el) => el.classList.add("reveal-init"));
const io = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
obs.unobserve(entry.target);
}
}
});
els.forEach((el) => io.observe(el));
The hidden state is added by JavaScript, on purpose. If JS never runs, or you’ve asked your system for reduced motion, everything just renders visible from the start instead of being stuck invisible forever. No framework, no island, no hydration cost, and it degrades gracefully on its own. Just markup and CSS like it’s 2010 again (non-derogatory, I know that was on the line for some… questionable designs). Once that clicked the question flipped: instead of “how do I build this in React,” it became “do I even need to,” and the answer was usually no
Going overboard: the demos
Here’s where the “overbuilding” in the title was hiding. I couldn’t pick a look for the site. So instead of choosing, I built several full design directions as separate demos and let them fight it out
That sounds like a waste of time, and maybe it was, but it was also the best Astro tutorial I could’ve given myself. Building the same site four different ways drills the component model into your head fast. By the third one I wasn’t fighting the framework anymore. Repetition sometimes can help a lot on this, making projects, even if it’s kinda… the same project, but with a new layout and actually new whole theme
But as I couldn’t choose, here comes the theme switcher
I did the thing you’re not supposed to do. I refused to choose. All four looks ship in the final site as switchable themes
The trick that makes that not-insane is almost entirely CSS. The home page renders all four design variants into the HTML, and a stylesheet hides every one except the active theme’s:
[data-home-variant] {
display: none;
}
html[data-theme="classic"] [data-home-variant="classic"],
html[data-theme="wired"] [data-home-variant="wired"],
html[data-theme="night-city"] [data-home-variant="night-city"],
html[data-theme="ghost-in-the-wired"] [data-home-variant="ghost-in-the-wired"] {
display: block;
}
The three hidden variants are display: none, so their islands never hydrate. Maybe not the most elegant, but you’re not paying in resources for the themes you’re not looking at. Recoloring is the same idea, every accent is a CSS variable, and each theme just re-skins the tokens:
[data-theme="wired"] {
--color-accent: #4ade80;
/* ...the rest of the palette */
}
The one bit of actual JavaScript is making sure your pick doesn’t flash. The theme lives in localStorage, and a tiny inline script runs during head parsing, before the first paint, to set it:
<script define:vars={{ themes, storageKey, metaColors }}>
try {
const stored = localStorage.getItem(storageKey);
if (stored && themes.includes(stored)) {
document.documentElement.dataset.theme = stored;
}
} catch {
/* storage blocked, default theme stands */
}
</script>
That’s the whole switcher. All four designs in the HTML, CSS picks the winner, one inline script kills the flash, and the “default” is just a constant I can change my mind about whenever I want. Which, knowing me, I kinda will…
The borrowed thingies
I didn’t hand-roll every fancy effect. A lot of the eye-candy started as components from MagicUI, an open library of animated React bits, and then got pulled apart to fit
What I grabbed and mostly kept: the aurora text that does the purple-to-teal gradient on my name for example (configurable, as everything, of course), the flickering grid that turned out to be THE card-hover effect across the whole site, meteors streaking some backgrounds, plus smaller ones like number ticker, typing animation, text animate, hyper text, and a retro grid. Off-the-shelf parts that saved me a ton of time
But I also kinda deleted a whole pile of them after building. Terminal, marquee, shine border, magic card, border beam, light rays, grid pattern, scroll progress, animated shiny text, all gone. Not because they were bad, but because they belonged to demos that didn’t make the cut. No sense keeping a fancy border effect around for a look I’d already retired
And then there’s the ones I kept but completely gutted, which is its own story, because that’s where the trouble started
Boss fight! > Performance <
By far the worst part of this whole build was performance. Not even close
Here’s what happens when you build four themes’ worth of animated canvas backgrounds, particle fields, flickering grids and meteor showers, and then you’re kinda yoloing Magic UI and Astro, and who doesn’t yet know which of these are cheap and which are quietly melting a core. If your laptop fan sounds like a jet engine and your CPU graph looks like a heart attack, let me know, I might need more work to do. Still, I opened the site and felt it. Scrolled and felt it more. It drained CPU like crazy and it was entirely my own fault for putting motion on goddamn everywhere
So most of those “borrowed toys” didn’t survive contact in their original form. The particle background got rewritten basically end to end. The original redrew every single frame at 60fps. I throttled it to a target framerate (And I mean, the target animations was just pixels turning on and off in different places of it, and it looked like low-fps anyway) and just skip the frames in between:
const frameInterval = 1000 / fps;
function animate(time) {
if (!isInView) return;
rafId = requestAnimationFrame(animate);
const elapsed = time - lastFrameTime;
if (elapsed < frameInterval) return; // not time for the next frame yet
lastFrameTime = time;
drawFrame(elapsed / baseFrameMs); // scale movement so it looks the same at any fps
}
Notice the if (!isInView) return at the top. That’s the other half: the loop only runs while the canvas is actually on screen. An IntersectionObserver flips isInView on and off, so the second the particles scroll out of view the whole thing stops dead. No point animating pixels nobody’s looking at. That offscreen-pause became a rule that every animated thing on the site obeys now
A few more lessons from the same fire dumpster:
- I stopped routing mouse movement through React state. Every mousemove was triggering a re-render of sorts, which is death by a thousand cuts. Now it writes to a plain variable the canvas reads directly
- Every effect checks
prefers-reduced-motionand just holds still if you’ve asked for it. That’s good for accessibility and it’s free CPU - The About page’s no React approach came straight out of this too. Transform and opacity only, both GPU-composited
Even before this post I tweaked some components a bit more. I wanted the particle field denser, but denser usually means more expensive. So instead of just adding particles I rewrote the draw step to be cheaper per particle, then added more of them. So, I got a busier effect that costs less than the old one did. That’s the whole performance arc in one move, more presence for a smaller bill. It only took me looking at my Ryzen’s temps to go real high to learn how to get there
What I’d tell past me
Astro’s mental flip, “nothing runs unless you say so”, is the one idea I wish I’d had on day one. Half my performance pain was me building like everything had to be alive, when the entire point of the framework is that it doesn’t
If you’re eyeing Astro for your first project: lean into static, treat every island as a cost you’re choosing to pay, let content collections carry your data, and assume any effect that looks cool is melting something until you’ve proven it isn’t
None of this is random examples, every snippet up there is real code from this site, and if you need more info about it, you can always ask me, I’ll try to put up a repo for issues and mostly to allow for comments. Either that, or I try to check how to add comments to Astro’s posts… as I go crazy again and start making wild easter eggs or more stuff. Already have some planned, not gonna lie
That last part’s optional. Probably don’t build four times the same page, either. But I don’t regret it!
Update, July 2026: about that React claim
A few sections up I said I don’t even need React for most of it. That held for about two weeks. Then I wanted a command palette (NAVI, press Ctrl+K, it’s real), and writing combobox keyboard handling and focus management by hand is the kind of thing I’ve done once and don’t need to do again. So the palette is a React island now, built on cmdk, and it was the right call. The boot screen you maybe saw on the way in? Plain Astro and one small script, no React