Frontend

Astro: Building Fast Content-Focused Sites

Mayur Dabhi
Mayur Dabhi
June 7, 2026
14 min read

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.

Which Astro version this assumes

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.

1

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.

Terminal
# 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
2

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.

Terminal: Adding Integrations
# 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
3

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.

Project Structure
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 First

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

src/components/BlogCard.astro
---
// 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.

src/pages/index.astro: Mixing Frameworks
---
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

src/content/config.ts
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

src/pages/blog/[slug].astro: Dynamic Blog Routes
---
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>
Schema Validation Runs at Build Time

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.

Static HTML Ocean: No JavaScript Navigation / Header Static HTML, 0 KB JavaScript Article Content Static HTML, 0 KB JS Rendered at build time No hydration cost 🚀 React Comment Box client:visible Hydrates when scrolled into view Ships React runtime + component ⚡ Footer Static HTML, 0 KB JavaScript

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)
Islands in Practice
---
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>
"window is not defined"

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?

astro.config.mjs: Configuring Output and Adapters
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' },
  },
});
Terminal: Build and Deploy Commands
# 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.

Using Astro's Image Component
---
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"
/>
Where the speed actually comes from

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:

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.

Astro JavaScript Static Sites Islands Architecture Web Performance Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.