komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Components & Islands••By Komal Nakrani

Astro Components: The Complete Guide

Astro components are .astro files that render HTML on the server: frontmatter, props, slots, scoped styles, scripts and islands, with Astro 7 examples.

#Astro#Components#Props#Slots#Islands

Astro components are .astro files that render to HTML on the server. Each one has a frontmatter script between --- fences, which runs at build or request time, and an HTML-like template below it. They take props and slots like components in any framework, but they ship no JavaScript to the browser, so they cost nothing at runtime.

This guide covers everything you need to write and edit Astro components in Astro 7: the file format, typed props, slots, layouts, styles, scripts, framework islands and server islands. The last section covers the patterns we use in every theme so a site stays easy to change.

Key Takeaways

  • An .astro component renders once on the server. It has no state, no re-renders and no client bundle.
  • Type props with interface Props in the frontmatter; Astro picks it up automatically.
  • <slot /> is Astro’s version of children. Named slots let a layout accept several regions.
  • <style> in a component is scoped by default. <script> is bundled, deduplicated and runs once per page.
  • Interactivity comes from framework components with client:* directives, or from server islands with server:defer.

Anatomy of an Astro component

Every .astro file has two parts:

---
// src/components/Greeting.astro
// 1. Component script: runs on the server only
const name = 'Astro';
const today = new Date().toLocaleDateString('en-US');
---
<!-- 2. Component template: HTML plus expressions -->
<p>Hello, {name}. Today is {today}.</p>

The frontmatter is TypeScript (or JavaScript). You can import other components, read files, call APIs with fetch(), and query content collections there. None of that code reaches the browser.

The template is a superset of HTML. Curly braces evaluate expressions, .map() renders lists, and && or a ternary handles conditions. Attributes work as in HTML, so you write class, not className.

---
const links = [
  { href: '/', label: 'Home' },
  { href: '/blog/', label: 'Blog' },
];
const isLoggedIn = false;
---
<nav>
  {links.map((link) => <a href={link.href}>{link.label}</a>)}
  {isLoggedIn ? <a href="/account/">Account</a> : <a href="/login/">Sign in</a>}
</nav>

For more ways to show and hide markup, see conditional rendering in Astro.

What an Astro component cannot do

Since the component renders once, there is no useState, no onClick handler and no effect hook. An onclick attribute would be serialised as a string, not wired to your frontmatter function. When you need behaviour in the browser, use a <script> tag or a framework island, both covered below.

Props, and how to type them

Props arrive on Astro.props. Declare an interface Props in the frontmatter and Astro uses it to type-check every place the component is used.

---
// src/components/Card.astro
interface Props {
  title: string;
  href: string;
  description?: string;
  tags?: string[];
}

const { title, href, description, tags = [] } = Astro.props;
---
<article class="card">
  <h3><a href={href}>{title}</a></h3>
  {description && <p>{description}</p>}
  {tags.length > 0 && (
    <ul>{tags.map((tag) => <li>{tag}</li>)}</ul>
  )}
</article>

Using it:

---
import Card from '../components/Card.astro';
---
<Card title="Routing" href="/blog/astro-routing-guide/" tags={['pages']} />

Run npx astro check and a missing title or a typo in href becomes a type error in your terminal and editor.

Passing through HTML attributes

Wrapper components often need to accept any attribute a native element accepts. Use the HTMLAttributes helper:

---
// src/components/Button.astro
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'a'> {
  variant?: 'primary' | 'ghost';
}

const { variant = 'primary', class: className, ...attrs } = Astro.props;
---
<a class:list={['btn', `btn-${variant}`, className]} {...attrs}>
  <slot />
</a>

class:list merges strings, arrays and objects into one class attribute and drops falsy values. It is the cleanest way to combine a base class with a variant and whatever the caller passed.

Slots and named slots

<slot /> renders whatever children the parent passed in. It is the equivalent of children in React.

---
// src/components/Panel.astro
---
<section class="panel">
  <slot />
</section>
<Panel>
  <h2>Shipping</h2>
  <p>Orders leave within two days.</p>
</Panel>

Named slots let one component accept several regions. The parent targets a slot with the slot attribute:

---
// src/components/Hero.astro
---
<header class="hero">
  <div class="hero-text">
    <slot name="title" />
    <slot />
  </div>
  <div class="hero-media">
    <slot name="media">
      <img src="/placeholder.svg" alt="" />
    </slot>
  </div>
</header>
<Hero>
  <h1 slot="title">Fast sites, finished</h1>
  <p>Default slot content goes here.</p>
  <img slot="media" src="/hero.jpg" alt="Product screenshot" />
</Hero>

Content inside <slot name="media"> is fallback content, shown only when the parent passes nothing for that slot. To check whether a slot was filled, use Astro.slots.has('media').

If you need to wrap several elements in one slot without adding a div, use <Fragment slot="title">. Our tip on Fragment and set:html covers that and rendering HTML strings safely.

Layouts are just components

A layout is a component that renders the page shell (<html>, <head>, header, footer) and a <slot /> for the page body. Nothing about it is special to Astro; it is a convention.

---
// src/layouts/BaseLayout.astro
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';

interface Props {
  title: string;
  description: string;
}
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{title}</title>
    <meta name="description" content={description} />
    <slot name="head" />
  </head>
  <body>
    <Header />
    <main><slot /></main>
    <Footer />
  </body>
</html>

Pages import it and wrap their content:

---
// src/pages/about.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About" description="Who we are.">
  <h1>About</h1>
</BaseLayout>

Layouts can nest. A BlogPost.astro layout can wrap its content in BaseLayout.astro and add a byline, date and table of contents. Markdown pages can also point at a layout with a layout frontmatter key, though most projects now render Markdown through content collections instead.

Styles: scoped by default

A <style> block inside a component only affects that component. Astro adds a unique attribute to the component’s elements and rewrites selectors to match it, so h3 { color: red } in Card.astro won’t touch any other h3.

<article class="card"><h3><slot /></h3></article>

<style>
  .card { padding: 1.5rem; border-radius: 0.75rem; }
  h3 { margin: 0; }
</style>

When you need to style children rendered through a slot, or third-party markup, use :global() for one selector or <style is:global> for the whole block:

<style>
  .prose :global(a) { text-decoration: underline; }
</style>

To pass a frontmatter value into CSS, use define:vars:

---
const { accent = '#2563eb' } = Astro.props;
---
<style define:vars={{ accent }}>
  .badge { background: var(--accent); }
</style>

The define:vars tip covers when to use it and when a plain CSS variable is better. The full picture, including Tailwind and design tokens, is in the Astro styling guide.

Scripts: client-side JavaScript without a framework

A <script> tag in an Astro component is processed by Astro: it is bundled, TypeScript is compiled, imports are resolved, and it is included once per page even if the component appears ten times.

---
// src/components/CopyButton.astro
const { text } = Astro.props;
---
<button class="copy" data-text={text}>Copy</button>

<script>
  document.querySelectorAll<HTMLButtonElement>('button.copy').forEach((btn) => {
    btn.addEventListener('click', async () => {
      await navigator.clipboard.writeText(btn.dataset.text ?? '');
      btn.textContent = 'Copied';
    });
  });
</script>

Data flows from server to script through data-* attributes, as above. Processed scripts are type module, so they are deferred and run after the HTML is parsed.

If you need a script left exactly as written (for an analytics snippet, say), add is:inline. Astro won’t bundle or deduplicate it, and it renders wherever you put it.

For small interactions such as menus, tabs, copy buttons and accordions, a component script is usually the lightest option: no framework runtime, just your code.

Framework components and client directives

When a component needs real state, write it in React, Preact, Vue, Svelte or Solid and use it as an island. Add the integration once:

npx astro add react

Then import and use the component with a client:* directive:

---
import Counter from '../components/Counter.tsx';
import Newsletter from '../components/Newsletter.svelte';
---
<Counter client:load start={5} />
<Newsletter client:visible />

Without a directive, the framework component renders to static HTML and ships no JavaScript, which is handy for reusing an existing React component purely for markup.

Directive Hydrates when Typical use
client:load Immediately on page load Above-the-fold interactive UI
client:idle Browser is idle Secondary widgets
client:visible Component scrolls into view Below-the-fold carousels, forms
client:media="(query)" Media query matches Mobile-only navigation
client:only="react" Browser only, no SSR Components that touch window on render

Props passed to an island are serialised into the HTML, so they must be serialisable: strings, numbers, arrays, plain objects, dates, maps and sets work; functions do not. You can pass Astro-rendered children into a framework component as slots, and they arrive as static HTML.

Choosing directives well is where most of an Astro site’s performance comes from. The lazy-loading islands tip shows how to delay heavy components without hurting UX. The directives reference lists every option.

Sharing state between islands

Islands don’t share a React tree, so context providers can’t span them. Use a small store library that works across frameworks (the Astro docs recommend Nano Stores) or communicate through the DOM with custom events.

Server islands with server:defer

Server islands solve the opposite problem: a mostly static page with one piece that must be personalised or fresh, such as a cart count, a user avatar or live stock. Add server:defer to an Astro component and Astro renders the rest of the page immediately, then fetches that component’s HTML from the server in a separate request.

---
import UserMenu from '../components/UserMenu.astro';
---
<header>
  <a href="/">Home</a>
  <UserMenu server:defer>
    <a slot="fallback" href="/login/">Sign in</a>
  </UserMenu>
</header>

The page itself can be static and cached at the CDN, while UserMenu runs on the server per request and can read cookies. The fallback slot shows until it arrives. Server islands need an adapter, since something has to render them on request. The Astro SSR guide explains adapters and when server islands beat full on-demand pages.

Component patterns that keep a site editable

These rules decide how easy a site is to change six months later.

Keep content out of components

Put copy, links and lists in data files or collections, and let components render them. A Footer.astro that maps over site.footerLinks from src/data/site.ts can be edited by someone who never opens the component.

// src/data/site.ts
export const site = {
  name: 'Northwind Studio',
  nav: [
    { href: '/work/', label: 'Work' },
    { href: '/about/', label: 'About' },
    { href: '/contact/', label: 'Contact' },
  ],
};

Small, single-purpose components

A Card, a Button, a SectionHeading. If a component takes more than six or seven props, it is usually two components.

One source of truth for design values

Colours, spacing and fonts belong in CSS custom properties or a Tailwind config, not in component styles. Then a rebrand is one file.

Default to .astro, upgrade to an island only when needed

Write the component in Astro first. If it later needs state, swap it for a framework component with the narrowest directive that works.

Type every prop

interface Props costs a few lines and turns astro check into a safety net for every edit.

The folder structure docs explain where components, layouts and data live in a Komal theme, and you can browse the themes to see the finished result per site type.

Common mistakes with Astro components

These come up in almost every code review of a first Astro project.

Expecting frontmatter to run in the browser

---
// This runs at build time, once. It never re-runs on click.
let count = 0;
function increment() { count++; }
---
<button onclick={increment}>{count}</button>

The button renders 0 and does nothing. Frontmatter is server code. Use a <script> or a framework island for anything that responds to the user.

Hydrating components that don’t need it

A client:load on a static header or a footer adds the framework runtime to every page for no benefit. Before adding a directive, ask whether the component has state or event handlers. If not, render it without one.

Importing the same CSS in many components

Importing global.css from several components can produce duplicate rules and ordering surprises. Import global styles once, in the base layout, and keep component styles in scoped <style> blocks.

Using is:inline scripts by habit

Inline scripts aren’t bundled or deduplicated. Put one in a card component rendered twelve times and you ship it twelve times. Reserve is:inline for third-party snippets and code that must run before paint, like a theme switcher.

Passing non-serialisable props to islands

<Chart client:visible formatter={(n) => `$${n}`} />

Functions can’t be serialised into HTML, so this fails. Pass data (currency="USD") and build the formatter inside the component.

Giant page files

A 600-line index.astro with every section inline is hard to edit. Extract each section into a component under src/components/sections/ and keep the page as a readable list of sections:

---
import Hero from '../components/sections/Hero.astro';
import Features from '../components/sections/Features.astro';
import Pricing from '../components/sections/Pricing.astro';
import FAQ from '../components/sections/FAQ.astro';
---
<BaseLayout title="Home" description="...">
  <Hero />
  <Features />
  <Pricing />
  <FAQ />
</BaseLayout>

Reordering the page is now moving one line.

Testing components

Astro’s Container API renders a component to a string outside a page, which is useful for unit tests with Vitest:

// src/components/Card.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import Card from './Card.astro';

test('Card renders title and link', async () => {
  const container = await AstroContainer.create();
  const html = await container.renderToString(Card, {
    props: { title: 'Routing', href: '/blog/astro-routing-guide/' },
  });
  expect(html).toContain('href="/blog/astro-routing-guide/"');
  expect(html).toContain('Routing');
});

For most sites, astro check plus a visual review of each page type is enough. Container tests earn their place in component libraries and themes reused across many sites.

FAQ

What is the difference between an Astro component and a React component?

An Astro component renders to HTML once, on the server, and ships no JavaScript. A React component used in Astro can do the same, or, with a client:* directive, hydrate in the browser and keep state. Use Astro components for structure and content, framework components for interactivity.

Can I use React hooks in an Astro component?

No. Hooks only run inside React components. Put the stateful part in a .jsx or .tsx file and render it from your Astro component with a client directive.

How do I pass a function as a prop to an island?

You can’t, because island props are serialised to HTML. Define the function inside the framework component, or communicate through events or a shared store.

Do Astro components support TypeScript?

Yes. The frontmatter is TypeScript by default, and interface Props gives you typed props checked by astro check and your editor.

Why is my style not applying to slotted content?

Scoped styles only match elements the component itself renders. Wrap the selector in :global(), for example .content :global(p), to style elements passed in through a slot.