Astro: Building Fast Content-Focused Sites
You build a marketing site or a blog in Next.js, run Lighthouse, and the performance score is lower than you expected for a page that's mostly text. You open the network tab and find a few hundred KB of JavaScript whose main job is to hydrate paragraphs that never change. For an app that's a fair price. For a content site it's dead weight.
Astro starts from the other end: it renders pages to HTML at build time and ships zero JavaScript by default. You add JS only to the components that need it. This post covers how that works in practice: project setup, the .astro component format, Content Collections, islands and client: directives, routing, output modes, and where Astro stops being a good fit.
The examples use the Astro 3/4 style APIs (output: 'hybrid', src/content/config.ts, post.render()), which you'll still see in a lot of existing projects. Astro 5 changed some of this, and I point out the differences where they matter. Check npx astro --version before copying config around.
What Makes Astro Different?
Most JavaScript frameworks assume you need interactivity everywhere. Next.js, Remix, and SvelteKit all send a JavaScript runtime to the browser and hydrate the page. On blogs, docs, and marketing pages, most of that work buys you nothing. Astro's starting assumption is that content sites mainly need HTML and CSS, not JavaScript.
Zero JavaScript by Default
An Astro page builds to plain HTML with no runtime JavaScript. Your components run during the build (or on the server in SSR mode), produce HTML, and that HTML is what the browser gets. Because there's no JS shell to download, parse, and boot before content shows up, a typical blog page renders quickly even on slow mobile connections.
Islands Architecture
The "islands" idea treats a page as an ocean of static HTML with a few interactive islands of JavaScript. In Astro you can drop in a React counter, a Vue accordion, or a Svelte chart, and Astro hydrates only that component. Everything around it stays plain HTML. You pay the JS cost for the widget, not for the whole page.
Multi-Framework Component Support
Astro doesn't care which UI library a component was written in. React, Vue, Svelte, Solid, Preact, and plain .astro components can live in the same project. That's handy when you're migrating between frameworks or want to reuse a component from another codebase. In practice I'd still pick one framework for islands: every extra one adds its own runtime to the pages that use it.
Astro vs Traditional Frameworks
| Feature | Astro | Next.js | Gatsby | SvelteKit |
|---|---|---|---|---|
| Default JS Shipped | 0 KB | ~70-200 KB | ~70-150 KB | ~20-60 KB |
| Rendering Modes | SSG, SSR, Hybrid | SSG, SSR, ISR | SSG, DSG | SSG, SSR |
| UI Framework | Any (React, Vue, Svelte…) | React only | React only | Svelte only |
| Islands / Partial Hydration | Native built-in | Not built-in | Not built-in | Not built-in |
| Content Collections | Built-in + type-safe | Manual setup | GraphQL layer | Manual setup |
| Best For | Content sites, blogs, docs | React apps, SaaS | Content / e-commerce | Svelte full-stack apps |
Installation and Setup
Setup is quick. The official CLI asks a few questions and offers starter templates for a blog, a portfolio, or a minimal project.
Create a New Astro Project
Run the official Astro CLI. The interactive wizard lets you pick a template, TypeScript settings, and whether to install dependencies automatically.
# Create a new Astro project (interactive wizard)
npm create astro@latest my-astro-site
# Or scaffold directly with the blog template
npm create astro@latest my-blog -- --template blog
# Navigate into the project
cd my-astro-site
# Start the development server (http://localhost:4321)
npm run dev
Add Integrations
astro add installs an integration (React, Tailwind, MDX, sitemap and so on) and edits astro.config.mjs for you. Read the diff it shows before you confirm.
# Add React support (auto-configures astro.config.mjs)
npx astro add react
# Add Tailwind CSS
npx astro add tailwind
# Add MDX for interactive Markdown
npx astro add mdx
# Add automatic sitemap generation
npx astro add sitemap
Understand the Project Structure
The layout is convention-based. Routes go in src/pages, content in src/content, and anything in public/ is copied as-is without processing.
my-astro-site/
├── public/ # Static assets served as-is (images, fonts, favicon)
│ └── favicon.svg
├── src/
│ ├── components/ # Reusable .astro and framework components
│ │ └── Card.astro
│ ├── content/ # Content Collections (Markdown, MDX, JSON, YAML)
│ │ ├── config.ts # Collection schemas defined here
│ │ └── blog/
│ │ └── first-post.md
│ ├── layouts/ # Page layout wrappers
│ │ └── BaseLayout.astro
│ └── pages/ # File-based routing: every file = a route
│ ├── index.astro # → /
│ ├── about.astro # → /about
│ └── blog/
│ └── [slug].astro # → /blog/:slug (dynamic route)
├── astro.config.mjs # Framework configuration
├── package.json
└── tsconfig.json
TypeScript works without extra setup. Component frontmatter, collection schemas, and config files all get type inference. One thing that catches people: astro dev and astro build don't type-check. Add astro check to CI, or type errors in .astro files will ship quietly.
Astro Component Syntax
A .astro file has two parts. At the top, between triple dashes, is the frontmatter: JavaScript or TypeScript that runs on the server. Below it is an HTML-like template. Logic up top, markup below, and none of the frontmatter code ever reaches the browser.
Anatomy of an Astro Component
---
// Frontmatter: runs on the server at build time (or per-request in SSR)
// Imports, TypeScript interfaces, and data-fetching logic live here
interface Props {
title: string;
description: string;
pubDate: Date;
slug: string;
tags?: string[];
}
const { title, description, pubDate, slug, tags = [] } = Astro.props;
const formattedDate = pubDate.toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
});
---
<!-- Template: HTML with Astro expressions in curly braces -->
<article class="blog-card">
<a href={`/blog/${slug}`}>
<h2>{title}</h2>
</a>
<time datetime={pubDate.toISOString()}>{formattedDate}</time>
<p>{description}</p>
{tags.length > 0 && (
<ul class="tags">
{tags.map(tag => <li>{tag}</li>)}
</ul>
)}
</article>
<!-- Scoped styles: only apply to THIS component's output -->
<style>
.blog-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 24px;
transition: box-shadow 0.2s;
}
.blog-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
h2 { color: #7c3aed; margin-bottom: 8px; }
.tags { display: flex; gap: 8px; list-style: none; padding: 0; }
.tags li { background: #f3f0ff; color: #7c3aed; padding: 2px 10px; border-radius: 12px; font-size: 0.8rem; }
</style>
Using Framework Components as Islands
After adding a framework integration, you can import React, Vue, or Svelte components directly into .astro files. By default they render to static HTML and ship no JS. You opt into hydration per component with a client: directive. If you forget the directive, the component shows up looking right but buttons do nothing, which is the most common "why is my onClick broken" question with Astro.
---
import BaseLayout from '../layouts/BaseLayout.astro';
import BlogCard from '../components/BlogCard.astro';
import Counter from '../components/Counter.jsx'; // React component
import Newsletter from '../components/Newsletter.vue'; // Vue component
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
---
<BaseLayout title="My Astro Blog">
<h1>Welcome to My Blog</h1>
<!-- Static Astro components: pure HTML output, zero JS shipped -->
<section class="posts-grid">
{posts.map(post => (
<BlogCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
slug={post.slug}
tags={post.data.tags}
/>
))}
</section>
<!-- React island: hydrates immediately on page load -->
<Counter client:load />
<!-- Vue island: only hydrates when scrolled into the viewport -->
<Newsletter client:visible />
</BaseLayout>
Content Collections
Sooner or later a Markdown post goes out with pubDate misspelled, and the page renders "Invalid Date". Content Collections stop that. You describe the frontmatter with a Zod schema, Astro generates TypeScript types from it, your editor autocompletes fields, and the build fails if a required field is missing or has the wrong type.
Defining a Collection Schema
import { z, defineCollection } from 'astro:content';
const blogCollection = defineCollection({
type: 'content', // 'content' for Markdown/MDX, 'data' for JSON/YAML
schema: z.object({
title: z.string().max(100),
description: z.string().max(200),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
author: z.string().default('Mayur Dabhi'),
}),
});
const authorsCollection = defineCollection({
type: 'data', // JSON or YAML files
schema: z.object({
name: z.string(),
bio: z.string(),
avatar: z.string().url(),
twitter: z.string().optional(),
github: z.string().optional(),
}),
});
// Export named collections; Astro generates types from this
export const collections = {
blog: blogCollection,
authors: authorsCollection,
};
Querying and Rendering Collections
---
import { getCollection, type CollectionEntry } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
// getStaticPaths tells Astro which slugs to pre-render at build time
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => {
return import.meta.env.PROD ? !data.draft : true;
});
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
type Props = { post: CollectionEntry<'blog'> };
const { post } = Astro.props;
// render() converts Markdown/MDX to an HTML component
const { Content, headings, remarkPluginFrontmatter } = await post.render();
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
{post.data.heroImage && <img src={post.data.heroImage} alt="" />}
<Content /> {/* Renders the Markdown body as HTML */}
</article>
</BaseLayout>
If a Markdown file's frontmatter doesn't match the schema (wrong type, missing required field, a title over the max length), the build stops with an error naming the file and the field. Two related gotchas: after changing the schema, restart the dev server or run astro sync so the generated types catch up. And in Astro 5 the config file moves to src/content.config.ts, collections use loaders, and you call render(post) instead of post.render().
Islands Architecture in Depth
Each island is a framework component on an otherwise static page. Without a directive it's rendered on the server and sends no JS. With a client: directive it hydrates in the browser, and only that component does. The directive also decides when hydration happens, which is where most of the real performance tuning happens.
Astro islands: only the interactive React island ships JavaScript, everything else is plain HTML
Client Directives Reference
| Directive | When It Hydrates | Best Use Case |
|---|---|---|
client:load |
Immediately on page load | Critical UI: nav dropdowns, search, auth forms |
client:idle |
When browser is idle (requestIdleCallback) | Non-critical widgets: chat bubbles, analytics banners |
client:visible |
When element enters the viewport | Below-fold content: comment sections, demos, carousels |
client:media="(max-width: 768px)" |
When the CSS media query matches | Mobile-only interactive menus |
client:only="react" |
Client-side only (skips SSR entirely) | Components using browser-only APIs (localStorage, canvas) |
---
import SearchBar from '../components/SearchBar.jsx'; // React
import LiveChat from '../components/LiveChat.jsx'; // React
import VideoPlayer from '../components/VideoPlayer.svelte';
import MobileMenu from '../components/MobileMenu.vue';
---
<!-- Hydrates immediately: search must work on first interaction -->
<SearchBar client:load />
<!-- Hydrates when the browser is idle after initial load -->
<LiveChat client:idle />
<!-- Hydrates only when the user scrolls to the video section -->
<VideoPlayer client:visible />
<!-- Hydrates only on mobile screens, saves JS on desktop -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- No directive = renders to HTML only, zero JavaScript shipped -->
<footer><p>© 2026 My Astro Site</p></footer>
Every directive except client:only still renders the component on the server first. If a component reads window, document, or localStorage at the top level, the build fails with ReferenceError: window is not defined. Move that access into useEffect (or onMount in Svelte), or use client:only="react". With client:only you have to name the framework, because Astro never renders it on the server and can't work it out on its own.
Layouts, Routing, and API Endpoints
Every .astro file in src/pages/ becomes a URL. Folders become path segments, square brackets make a segment dynamic, and .ts files in the same tree can serve JSON instead of HTML.
Layouts are Astro components that wrap pages and provide shared structure. Use the <slot /> element to render child content.
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
description?: string;
}
const { title, description = 'My Astro Site' } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content={description}>
<title>{title}</title>
<link rel="sitemap" href="/sitemap-index.xml">
</head>
<body>
<header>
<nav><a href="/">Home</a> <a href="/blog">Blog</a></nav>
</header>
<main>
<slot /> {/* Page content renders here */}
</main>
<footer><p>Built with Astro</p></footer>
</body>
</html>
getStaticPaths() tells Astro which URL segments to generate at build time for dynamic routes.
---
// src/pages/tags/[tag].astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
// Collect all unique tags across all posts
const tags = [...new Set(posts.flatMap(p => p.data.tags))];
return tags.map(tag => ({
params: { tag },
props: {
posts: posts.filter(p => p.data.tags.includes(tag)),
},
}));
}
const { tag } = Astro.params;
const { posts } = Astro.props;
---
<BaseLayout title={`Posts tagged: ${tag}`}>
<h1>Posts tagged: {tag}</h1>
<ul>
{posts.map(post => (
<li><a href={`/blog/${post.slug}`}>{post.data.title}</a></li>
))}
</ul>
</BaseLayout>
Create .ts files in src/pages/api/ to expose JSON endpoints alongside your HTML pages.
// src/pages/api/posts.ts
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
export const GET: APIRoute = async () => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const payload = posts.map(post => ({
slug: post.slug,
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate.toISOString(),
tags: post.data.tags,
}));
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
// POST endpoint for form submissions (requires SSR or hybrid mode)
export const POST: APIRoute = async ({ request }) => {
const data = await request.json();
// Handle the submission...
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
Building and Deploying
The output mode comes down to one question: does anything need to be computed per request?
- static (default): Plain HTML/CSS/JS generated at build time. Deploy to any CDN or static host: Netlify, Vercel, Cloudflare Pages, GitHub Pages. Right for blogs, docs, and marketing sites where content changes when you deploy.
- server: Every page renders on request. You need this for auth, per-user content, or data that can't be stale. Requires a Node.js or serverless adapter.
- hybrid: Static by default, with individual pages opting into SSR via
export const prerender = false. Good for a mostly static site with a couple of dynamic routes. Astro 5 removed'hybrid'and folded this behaviour into'static': keepoutput: 'static', add an adapter, and setprerender = falseon the pages that need it. Leaving'hybrid'in the config after upgrading makes the build fail at config validation.
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
import vercel from '@astrojs/vercel/serverless'; // SSR on Vercel
export default defineConfig({
site: 'https://mysite.com', // Required for sitemap and canonical URLs
output: 'hybrid', // 'static' | 'server' | 'hybrid'
adapter: vercel(), // Required for 'server' or 'hybrid' modes
integrations: [
react(),
tailwind(),
sitemap(), // Auto-generates /sitemap-index.xml
],
image: {
// Optimise images with Sharp (auto-converts to WebP/AVIF)
service: { entrypoint: 'astro/assets/services/sharp' },
},
});
# Build for production (outputs to ./dist/)
npm run build
# Preview the production build locally before deploying
npm run preview
# Deploy static output to Netlify
npx netlify deploy --prod --dir=dist
# Deploy to Vercel (auto-detects Astro, applies correct settings)
npx vercel deploy
# Deploy to Cloudflare Pages
npx wrangler pages deploy dist
Built-in Image Optimisation
Since Astro 3.0 there's a built-in <Image> component that resizes images, converts them to WebP or AVIF, fills in dimensions so the layout doesn't shift, and adds lazy loading. It only optimises images you import from src/ (or remote images you've allowed in config). Files in public/ are served untouched, so a 4 MB hero JPEG there stays 4 MB.
---
import { Image, Picture } from 'astro:assets';
import heroImage from '../assets/hero.jpg'; // Local image import
---
<!-- Astro automatically:
✓ Converts to WebP/AVIF for smaller file sizes
✓ Adds width/height attributes to prevent CLS
✓ Adds loading="lazy" and decoding="async"
✓ Generates responsive srcset for different screen sizes -->
<Image
src={heroImage}
width={800}
height={400}
alt="Hero image: describe the content meaningfully"
format="webp"
quality={85}
/>
<!-- Picture for art direction with multiple formats -->
<Picture
src={heroImage}
formats={['avif', 'webp']}
width={800}
height={400}
alt="Hero image"
/>
With no JS by default there are no render-blocking scripts, the Image component takes care of most layout shift, and static HTML on a CDN gives you a low Time to First Byte. A fresh Astro site usually scores well on Lighthouse without tuning. You can still undo all of it with a heavy client:load island, third-party tag scripts, or unoptimised images in public/. Measure the built site with npm run preview, not the dev server.
When I'd use Astro (and when I wouldn't)
I'd reach for Astro when the site is mostly content: blogs, docs, portfolios, marketing pages, a landing page with a signup form and a pricing toggle. It also makes sense if you're leaving Gatsby and want typed content without a GraphQL layer, or if you have React or Vue components you'd like to reuse without shipping a whole SPA.
I wouldn't use it for things that are an application from the first screen:
- Dashboards and admin panels with lots of shared client state. You end up hydrating nearly everything, and islands can't share React context with each other. You'd need something like nanostores to pass state between them.
- Real-time collaborative tools where the page is one big interactive surface. Next.js, SvelteKit, or a plain Vite SPA will feel more natural.
- Sites with thousands of pages that change hourly. Static builds get slow and every content edit needs a rebuild. You can switch to SSR, but then you're running a server and the "it's just HTML on a CDN" advantage is gone.
Before going to production, I check a few things: site is set in astro.config.mjs (the sitemap and canonical URLs depend on it), astro check runs in CI, drafts are filtered out of every getCollection call (not only the page routes, the API endpoint and RSS feed too), and each client:load is really needed or could be client:visible. If the site is mostly static and those boxes are ticked, Astro is hard to beat for this kind of work.