Why Astro Changed How I Build Websites
Static-first, zero JS by default, content collections that make sense—how Astro 5 replaced my traditional framework workflow and why I'm not going back.
I spent years building sites with traditional frameworks. You know the pattern: pick React or Vue, scaffold a project, ship a JavaScript bundle the size of a small novel, and pray the performance scores come back respectable. It worked. Sort of. But I kept feeling like I was solving the wrong problem.
Then I rebuilt my portfolio with Astro, and my entire approach changed. Not because Astro has fancier animations or better developer tools, but because it starts from a different assumption: ship HTML, not JavaScript. Everything else follows from that.
The problem nobody talks about
Most web frameworks share the same starting assumption: you’re building a JavaScript application that happens to render HTML. Next.js, Nuxt, SvelteKit all begin with JavaScript and sort out the HTML later. Server-side rendering helps, but you’re still shipping a runtime. The hydration tax applies. The bundle loads.
Here’s what that looks like in practice. A typical Next.js blog page ships around 180KB of JavaScript. A Gatsby page pushes 210KB. For a blog. Content that never changes, has no interactive elements, and doesn’t need a virtual DOM. And yet we load an entire framework just to put text on a screen.
I accepted this for a long time. Everyone did. It’s just how things work, right?
Astro challenged that. Not by being a better JavaScript framework, but by not being a JavaScript framework at all.
Zero JS by default
The most important thing Astro does: components render to plain HTML and CSS. No client runtime. No hydration. No JavaScript bundle unless you ask for one.
---
// This component ships ZERO JavaScript
const posts = await getCollection("articles");
---
<section>
{posts.map((post) => (
<article>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
</article>
))}
</section>
That component becomes static HTML at build time. The browser receives markup and styles. No framework runtime, no hydration step, no interactivity nobody asked for.
This isn’t an optimization you bolt on later. It’s the default. You opt into JavaScript, not out of it. That inversion matters more than I expected. It forces a question every time you build a component: does this actually need to run in the browser? Most of the time, no.
Islands: the architecture that clicks
When you do need interactivity — form validation, a navigation toggle, an animated counter — Astro gives you islands. The page is static HTML, and interactive pieces hydrate independently.
---
import Navigation from "../components/Navigation.astro";
import ContactForm from "../components/ContactForm.vue";
import Footer from "../components/Footer.astro";
---
<Navigation />
<ContactForm client:idle />
<Footer />
Navigation and Footer ship zero JavaScript. ContactForm hydrates when the browser becomes idle, via the client:idle directive. The form gets interactivity. Everything else stays static.
Astro has several directives that control when hydration happens:
client:load— hydrate immediately, for critical interactive elementsclient:idle— hydrate once the browser is idle, for below-the-fold componentsclient:visible— hydrate when the component scrolls into view, for heavy widgets far down the pageclient:media— hydrate when a media query matches, for responsive interactive elementsclient:only— skip SSR entirely, render only on the client
Each island loads in parallel. A heavy image carousel with client:visible won’t block a nav menu with client:load. They’re independent — the slow one doesn’t hold up the fast one.
This changed how I think about interactivity. Instead of “this page uses Vue,” I started thinking “this form uses Vue.” What ships to the browser shrinks from entire pages to individual components. The performance difference shows up in real measurements.
Server islands: deferred personalization
Astro 5 introduced server islands with server:defer. This covers pages that are mostly static but need small personalized pieces — a user avatar, a dynamic price, a “last viewed” section.
---
import PersonalizedContent from "../components/PersonalizedContent.astro";
---
<main>
<h1>Welcome back</h1>
<!-- This renders in parallel, not blocking the main page -->
<PersonalizedContent server:defer />
</main>
The main page shell renders and caches. The server island fetches its data in parallel and streams in. Users see the bulk of the content immediately, with personalized pieces filling in shortly after. No layout shift, no blocking, no tension between cacheability and personalization.
Content collections: Markdown with type safety
Most frameworks treat content as an afterthought. You wire up a CMS, write API calls, maybe add a GraphQL layer, and hope the types align. Astro treats content differently.
In Astro 5, content collections live in src/content.config.ts and use the Content Layer API. You define where content comes from and what shape it has:
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const articles = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/articles" }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
date: z.date(),
image: image().optional(),
tags: z.array(z.string()).optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { articles };
That’s my actual content config — not simplified. Zod validates every markdown file at build time. Miss a required field? Build fails. Wrong date format? Build fails. Wrong image path? Build fails. The content ends up as type-safe as any TypeScript module.
Querying is straightforward:
const posts = await getCollection("articles", ({ data }) => {
return data.draft !== true;
});
No database queries, no API calls at runtime, no CMS webhook failures. Content lives in version control alongside the code. Editing an article is editing a markdown file. Deploying is a git push.
The glob loader also means content doesn’t have to live in src/content/. Point it at any directory. Load from JSON, YAML, or markdown. Or write a custom loader that pulls from a CMS:
const products = defineCollection({
loader: async () => {
const response = await fetch("https://api.example.com/products");
const data = await response.json();
return data.map((product) => ({ id: product.slug, ...product }));
},
schema: z.object({
title: z.string(),
price: z.number(),
}),
});
The Content Layer API caches between builds, supports incremental updates with digest tokens, and keeps types consistent from schema to template. For content-heavy sites without a CMS, this is the right model.
The framework comparison
Next.js and Nuxt have their place. If you’re building a dashboard with real-time data, complex client state, and authenticated routes, those frameworks make sense. They’re designed for that.
For content-heavy sites — portfolios, blogs, marketing pages, docs — shipping 180KB of JavaScript to display text is the wrong architecture. Here’s what the numbers look like:
| Metric | Next.js (App Router) | Nuxt | Astro 5 |
|---|---|---|---|
| JS shipped (content page) | ~180KB | ~150KB | 0KB |
| JS shipped (interactive page) | ~165KB | ~140KB | ~12KB |
| Build time | ~45s | ~30s | ~8s |
| Time to Interactive | ~2.1s | ~1.8s | ~0.3s |
The gap is structural. Astro builds faster because there’s less to process, pages load faster because there’s less to transfer, and interactivity kicks in faster because there’s less to hydrate. These aren’t things you tune after the fact — they follow from the architecture.
The question isn’t which framework is better. It’s which problems each was built for. Astro was built for rendering content efficiently.
Developer experience
What surprised me about Astro wasn’t the performance — that’s a predictable result of shipping less JavaScript. What surprised me was how much the component model gets out of your way.
Astro components feel like writing HTML with just enough JavaScript to be useful. The frontmatter between the --- fences runs on the server. The template below is mostly HTML with some expressions. No JSX, no virtual DOM, no hooks, no state boilerplate for content that has no state.
---
const title = "Why Astro Changed How I Build Websites";
const description = "How Astro 5 replaced my traditional framework workflow";
---
<section>
<h1>{title}</h1>
<p>{description}</p>
</section>
<style>
h1 {
font-size: 2.5rem;
line-height: 1.1;
}
</style>
Styles are scoped by default. No CSS modules setup, no Tailwind purge config (though Tailwind works alongside it fine), no class name collisions. CSS stays in the component.
Build speed is worth mentioning. Using Bun, my site does a full rebuild in under a second. Not hot module replacement — an actual full rebuild. When you’re iterating on content, that removes the pause between making a change and seeing it. You just work.
What I actually built
I’m not writing this from a tutorial perspective. I rebuilt my entire portfolio with Astro 5, added Swup for page transitions and GSAP for scroll animations, and ended up with a site that scores 100 on Lighthouse while behaving like a single-page application.
Astro generates static HTML for every page. Swup intercepts navigation and swaps content without full reloads. GSAP handles animations. Alpine.js manages the contact form and nav toggle — the two places that actually need client-side state.
Each library does one thing. They don’t compete for control.
When Astro makes sense (and when it doesn’t)
Astro isn’t the right tool for every project. Here’s where it fits and where it doesn’t.
Astro makes sense when:
- Your site is primarily content (blogs, portfolios, docs, marketing)
- You want fast builds and fast pages without manual optimization
- You need interactivity in specific places, not across the whole app
- You want type-safe content management without a CMS
- You’re tired of shipping JavaScript for static pages
Astro is a harder sell when:
- You need complex client-side routing with authenticated flows
- Your app is mostly interactive with minimal static content
- You rely on real-time features (WebSockets, live collaboration)
- Your team is deeply invested in React/Nuxt tooling and doesn’t want to switch models
Most marketing sites, portfolios, and blogs are a better fit for Astro’s approach. If you’re building Figma or Notion, you need something else.
The actual shift
What Astro changed for me wasn’t the tooling. It was the default assumption about what a web page is.
With React or Vue, every page is assumed to need JavaScript — then you optimize to strip out what you don’t use. With Astro, every page starts as static HTML and you add JavaScript only where it earns its place. That flip changes how you scope components, how much state you reach for, and how seriously you take bundle size (less, because it’s already small).
Astro treats pages as documents. Most pages are documents. The frameworks that took over the last decade started from applications and worked backward. Astro doesn’t have that legacy, and it shows.
Resources:
- Astro Documentation: https://docs.astro.build/
- Islands Architecture: https://docs.astro.build/en/concepts/islands/
- Content Layer API: https://astro.build/blog/content-layer-deep-dive
- Content Loader Reference: https://v5.docs.astro.build/en/reference/content-loader-reference/
Be awesome.
Keep building magic. ✊
Petar 🥃