Building Static Sites with Astro: Component Islands, Content Collections and Multi-Framework UI

Key takeaways

How Astro ships mostly-static HTML with interactive islands, organizes content with collections, lets you mix UI frameworks, and deploys to common hosts, with examples for each step.

Introduction: Why Astro?

Astro is a web framework designed for content-rich websites that prioritizes performance by shipping zero JavaScript by default. It was created by Fred K. Schott and the team behind Snowpack, and it is aimed at blogs, documentation, marketing sites and other pages where most of the content does not change after it is rendered.

Why it tends to be fast

Astro renders every page to HTML at build time (or on the server) and, by default, sends no framework runtime to the browser. A component only ships JavaScript if you mark it as an island with a client:* directive, and even then only that component hydrates. For content pages this means the browser downloads and executes far less script, which is what usually drives Time to Interactive and Total Blocking Time. The flip side is that highly interactive apps, where most of the page is dynamic, gain little from this model.

Where it fits well:

  • Marketing sites — mostly static content where load speed and crawlable HTML matter
  • Documentation sites — content-heavy, with a few interactive widgets such as search
  • Blogs and portfolios — Markdown content with typed frontmatter
  • Product pages — static product content with a small interactive cart or gallery

Problems it addresses

Large JavaScript bundles for static content. A single-page app ships its framework runtime and the code for every component on the page, even for text that never changes. Astro ships none of that by default and adds JS only for the islands you mark.

Search engines and slow devices. Client-side rendered pages show content only after JavaScript runs. Astro pages are complete HTML when they arrive, so crawlers, link previews and low-end phones see the content immediately.

Build time. Large static sites spend most of their build time rendering pages and processing images. Astro uses Vite, renders .astro components to HTML without a client-side React tree, and caches content collections between builds, which keeps builds manageable. Build time still grows with page count, so for thousands of pages image optimization and caching settings matter more than the framework choice.

Framework choice. Astro can render React, Vue, Svelte and other components side by side through integrations, which helps when migrating or when a team reuses existing components.


What is Astro?

Key Features

  • Zero JS by default: .astro components render to HTML and CSS only
  • Component Islands: hydration only where you ask for it
  • Multi-Framework: React, Vue, Svelte, Solid and others through integrations
  • Content Collections: typed Markdown/MDX and data files with schema validation
  • Vite-based: fast dev server and the Vite plugin ecosystem

Project Setup

Installation

npm create astro@latest

The wizard asks for a template, TypeScript strictness and whether to install dependencies; npm run dev then starts the dev server on port 4321.

Project Structure

my-astro-site/
├── src/
│   ├── components/
│   │   └── Header.astro
│   ├── layouts/
│   │   └── Layout.astro
│   ├── pages/
│   │   ├── index.astro
│   │   └── blog/
│   │       └── [slug].astro
│   ├── content/
│   │   └── blog/
│   │       └── post-1.md
│   └── content.config.ts
├── public/
└── astro.config.mjs

Routing is file-based: every file in src/pages/ becomes a URL, and [slug].astro is a dynamic route whose values come from getStaticPaths(). Files in public/ are copied to the output unchanged (favicons, robots.txt), while assets imported from src/ go through Vite and get hashed file names.


Components

Astro Components

---
// src/components/Card.astro
interface Props {
  title: string;
  description: string;
}
const { title, description } = Astro.props;
---
<div class="card">
  <h2>{title}</h2>
  <p>{description}</p>
</div>
<style>
  .card {
    padding: 1rem;
    border: 1px solid #ccc;
    border-radius: 8px;
  }
</style>

An .astro file has two parts. The code between the --- fences is the component script: it runs on the server at build time (or per request in server mode), never in the browser, so it can read files, call APIs with secret keys, or query a database. Everything below the fence is the template, an HTML superset with {expressions}. Declaring interface Props gives Astro.props a type, and callers get type errors for missing props.

The <style> block is scoped automatically: Astro adds a unique attribute to the elements in this component and rewrites the selectors, so .card here cannot leak into other components. Use <style is:global> when you really want global CSS. A common surprise is that scoped styles do not reach into child components; styling a child’s internals needs :global() inside the selector or a class passed as a prop.

Layout

---
// src/layouts/Layout.astro
interface Props {
  title: string;
}
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
  </head>
  <body>
    <header>
      <nav>
        <a href="/">Home</a>
        <a href="/blog">Blog</a>
      </nav>
    </header>
    <main>
      <slot />
    </main>
  </body>
</html>

A layout is just a component that renders the page shell and a <slot /> where the page’s content goes. Pages use it as <Layout title="Home">...</Layout>. Named slots (<slot name="sidebar" />) let a page fill several regions.


Component Islands

client:load

---
import Counter from '../components/Counter.jsx';
---
<!-- Hydrate immediately on page load -->
<Counter client:load />

Without a directive, <Counter /> would still render, but only as static HTML: the button appears and does nothing when clicked, because no React code was sent. This is the most common “my component is broken in Astro” report, and the answer is almost always a missing client:* directive. With the directive, Astro renders the component’s HTML on the server, then loads React and the component’s code in the browser and hydrates just that element.

client:visible

<!-- Hydrate when visible in viewport -->
<HeavyComponent client:visible />

client:idle

<!-- Hydrate when browser is idle -->
<Chat client:idle />

client:only

<!-- Render only on client without SSR -->
<ClientOnlyWidget client:only="react" />

The directives only change when hydration happens. client:visible uses an IntersectionObserver, so a chart at the bottom of a long page costs nothing until the reader scrolls there. client:idle waits for requestIdleCallback. client:only skips server rendering entirely, which is needed for components that touch window or localStorage during render, and it requires naming the framework because Astro cannot infer it without rendering. The trade-off is that the area stays empty until the script runs, so give it a placeholder with <div slot="fallback"> or fixed dimensions to avoid layout shift.

Islands are independent. Two React islands on the same page do not share a React tree, so React context does not flow between them. Sharing state across islands needs something outside the frameworks, such as a small store library (Astro’s docs suggest nanostores) or custom events.


Content Collections

Configuration

// src/content.config.ts  (Astro 5; Astro 4 used src/content/config.ts)
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()),
    author: z.string(),
  }),
});

export const collections = { blog };

The schema is a Zod object, and every Markdown file’s frontmatter is validated against it when the site builds. A missing description or a misspelled key fails the build with a message naming the file and the field, instead of rendering undefined on a page nobody checks. z.coerce.date() accepts both YAML dates and quoted date strings. The same schema generates TypeScript types, so post.data.title is typed as string everywhere you use it.

Astro 5 introduced the Content Layer: collections are defined with a loader, and glob() is the built-in loader for local files. Loaders can also pull entries from a CMS or an API at build time, and the entries are cached between builds. Projects upgraded from Astro 4 can keep the legacy type: 'content' form for a while, but the loader form is where new features land.

Writing Markdown

---
title: 'My First Post'
description: 'This is my first post.'
pubDate: 2024-09-27
tags: ['astro', 'blog']
author: 'JB'
---
# Hello World
This is my first post!

The frontmatter must be the very first thing in the file, between two --- lines, and it must be valid YAML. An unclosed quote, as in title: 'My First Post without the closing ', makes the YAML parser fail and the build stop; with a schema, a field that is present but in the wrong type fails with a clear validation error instead.

Using in Pages

---
// src/pages/blog/[slug].astro
import { getCollection, render } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---
<Layout title={post.data.title}>
  <article>
    <h1>{post.data.title}</h1>
    <p>{post.data.description}</p>
    <Content />
  </article>
</Layout>

getStaticPaths() runs once at build time and returns one entry per page to generate; each params.slug becomes a URL and props is passed to that page. In Astro 5 entries are identified by post.id (derived from the file path) and rendered with the render() function; Astro 4 used post.slug and post.render(), which is the most common error after upgrading. getCollection also accepts a filter, for example getCollection('blog', ({ data }) => !data.draft), which is the usual way to keep drafts out of production.


Multi-Framework

React Integration

npx astro add react
---
import ReactCounter from '../components/ReactCounter.jsx';
---
<ReactCounter client:load />

Vue Integration

npx astro add vue
---
import VueComponent from '../components/VueComponent.vue';
---
<VueComponent client:visible />

astro add installs the integration package and the framework, then updates astro.config.mjs. Mixing frameworks works, but each one adds its own runtime to pages that use it, so a page with a React island and a Vue island downloads both React and Vue. It is a good path for migrating or reusing existing components; for new work, one framework for interactive parts keeps bundles smaller. When two JSX frameworks are installed (React and Preact or Solid), tell each integration which files it owns with its include option, otherwise Astro may pick the wrong renderer for a .jsx file.


API Routes

// src/pages/api/posts.json.ts
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';

export const GET: APIRoute = async () => {
  const posts = await getCollection('blog');
  return new Response(JSON.stringify(posts.map((p) => ({ id: p.id, ...p.data }))), {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
    },
  });
};

export const prerender = false; // needed for POST: runs per request

export const POST: APIRoute = async ({ request }) => {
  const data = await request.json();
  // Processing logic
  return new Response(JSON.stringify({ success: true }), {
    status: 201,
    headers: {
      'Content-Type': 'application/json',
    },
  });
};

Endpoints behave differently depending on the output mode. In a fully static build, a GET endpoint runs once at build time and its response is written to a file, here /api/posts.json, which is handy for search indexes or RSS-like feeds. A POST handler, or anything that depends on the request, needs server rendering: an adapter for your host (@astrojs/node, @astrojs/cloudflare, @astrojs/vercel and others) and export const prerender = false on the route. Without an adapter, the build fails and tells you that an adapter is required for on-demand rendering. Note that export const prerender applies to the whole file, so a static GET and a dynamic POST cannot differ in one file; split them if you need both.


Deployment

Cloudflare Pages

npm run build
// package.json
{
  "scripts": {
    "build": "astro build",
    "preview": "astro preview"
  }
}

For a static site, astro build writes plain files to dist/, and any static host can serve them. On Cloudflare Pages you set the build command to npm run build and the output directory to dist. astro preview serves the built output locally, which is worth running before deploying because some problems (wrong asset paths, a base option that does not match the hosting path, missing trailing slashes) only appear in the built site, not in the dev server. Set site in astro.config.mjs to the production URL so canonical URLs, sitemaps and RSS feeds use absolute links. When a site grows to many thousands of pages, build memory can become the limit; raising Node’s heap with NODE_OPTIONS=--max-old-space-size=... is a common first fix before optimizing the image pipeline.


When Astro is the right choice, and when it is not

Astro fits sites where most pages are content that looks the same for every visitor: blogs, documentation, marketing pages, portfolios. The page ships as HTML, and only the islands you mark with a client:* directive pay for JavaScript. It fits less well when nearly every screen is an interactive application, such as a dashboard or an editor with shared client state across the page. There, most components become islands, you coordinate state between them by hand, and a framework built around client rendering (Next.js, SvelteKit, Nuxt) is simpler.

The mistake that erases Astro’s advantage is adding client:load by default. Each island with client:load hydrates immediately and loads its framework runtime, so a page with many of them ships about as much JavaScript as an SPA. Start with no directive, add client:visible or client:idle for components below the fold or not needed right away, and keep client:load for what the user touches first.


Frequently Asked Questions (FAQ)

Q. How does it compare to Next.js?

A. For content sites Astro usually ships less JavaScript, because pages are plain HTML and only the islands you mark hydrate. Next.js assumes a React runtime on every page, which pays off when most of the app is dynamic (dashboards, authenticated flows).

Q. Why does my React component render but not respond to clicks?

A. It has no client:* directive, so Astro rendered it to static HTML and sent no JavaScript. Add client:load (or client:visible / client:idle) where you use it.

Q. Which client directive should I use: client:load, client:idle, or client:visible?

A. client:load hydrates right away and suits interactive UI that must work immediately above the fold, such as a navigation menu. client:idle waits until the browser is idle and fits lower-priority widgets, and client:visible hydrates only when the component scrolls into view, which is ideal for comments or charts further down. client:only skips server rendering entirely and needs the framework name, for example client:only="react".

Q. After upgrading to Astro 5, post.slug and post.render() are undefined. Why?

A. Collections using the Content Layer identify entries by id and render them with the render() function imported from astro:content. Replace post.slug with post.id and await post.render() with await render(post).