Skip to content

Slots & Composition

A component often wraps content it does not know in advance. The markup a parent puts between the component’s tags is rendered wherever <slot /> appears — that is the default slot.

src/components/Panel.astro
---
const { heading } = Astro.props;
---
<section class="panel">
<h2>{heading}</h2>
<slot />
</section>
<Panel heading="Notes">
<p>This paragraph lands in the default slot.</p>
</Panel>

One default slot is not enough for a real layout. Named slots give you several labeled holes. The component declares <slot name="x" />; the parent fills it with slot="x" on a child:

src/components/Shell.astro
<header><slot name="header" /></header>
<main><slot /></main> <!-- default slot -->
<footer><slot name="footer" /></footer>
<Shell>
<h1 slot="header">Page title</h1>
<p>Body content fills the default slot.</p>
<small slot="footer">© 2026</small>
</Shell>

A named slot’s slot="x" also works on a <Fragment> — useful when a single named slot needs to hold several sibling elements without introducing a wrapper node:

src/components/FragmentNamedSlot.astro
<header><slot name="header" /></header>
<main><slot /></main>
src/components/FragmentNamedSlotHost.astro
---
import FragmentNamedSlot from './FragmentNamedSlot.astro';
---
<FragmentNamedSlot>
<Fragment slot="header">
<h1>Title</h1>
<p>Subtitle</p>
</Fragment>
<p>Body</p>
</FragmentNamedSlot>

Proven with the Container API — both elements inside the Fragment land in the header slot, with no extra wrapper element around them:

<header><h1>Title</h1><p>Subtitle</p></header><main><p>Body</p></main>

Fallback content, proven passed vs. not passed

Section titled “Fallback content, proven passed vs. not passed”

A <slot> can hold default content that renders only when the parent passes nothing for that slot:

src/components/FallbackButton.astro
<button class="btn"><slot>Click me</slot></button>
src/components/FallbackHost.astro
---
import FallbackButton from './FallbackButton.astro';
---
<FallbackButton />
<FallbackButton>Save</FallbackButton>

Rendered through the Container API — the first instance (no children passed) falls back to “Click me”; the second (children passed) overrides it with “Save”:

<button class="btn">Click me</button><button class="btn">Save</button>

Inside the component you can ask whether a slot received content, via Astro.slots.has, and render structure conditionally:

---
const hasAside = Astro.slots.has('aside');
---
<div class:list={["layout", { "has-aside": hasAside }]}>
<main><slot /></main>
{hasAside && (
<aside><slot name="aside" /></aside>
)}
</div>

This lets a component adapt its markup — for example, only render the aside wrapper when there is actually an aside to show, so you do not emit an empty element.

Scoped-style slots: Astro.slots.render(name, args)

Section titled “Scoped-style slots: Astro.slots.render(name, args)”

<slot> on its own only forwards markup — it can’t hand the parent a value to render with. For that, the parent passes a function as the slot’s content, and the component calls Astro.slots.render('default', args) with whatever arguments it wants to expose (an item from a loop, computed state) — the same “render prop” / “scoped slot” idea from Vue and Svelte:

src/components/ScopedChild.astro
---
const rendered = await Astro.slots.render('default', ['scoped-arg']);
---
<div id="scoped" set:html={rendered} />
src/components/ScopedParent.astro
---
import ScopedChild from './ScopedChild.astro';
---
<ScopedChild>
{(arg: string) => <em>arg was {arg}</em>}
</ScopedChild>

Proven through the Container API — the string 'scoped-arg' passed as Astro.slots.render’s second argument reaches the function child’s arg parameter:

<div id="scoped"><em>arg was scoped-arg</em></div>

Reach for this when a component needs to hand the parent computed data and let the parent decide the markup — a table row component exposing the row’s data to a custom cell renderer, for example.

A layout that wraps another layout has to relay the slots it receives, not just its own body — <slot name="x" slot="x" /> reads from the outer slot and immediately re-offers it as the inner component’s slot of the same name:

src/layouts/InnerLayout.astro
<header><slot name="head" /></header>
<main><slot /></main>
src/layouts/OuterLayout.astro
---
import InnerLayout from './InnerLayout.astro';
---
<InnerLayout>
<slot name="head" slot="head" />
<slot />
</InnerLayout>
src/pages/page-using-outer.astro
---
import OuterLayout from '../layouts/OuterLayout.astro';
---
<OuterLayout>
<link slot="head" rel="canonical" href="https://example.com/x" />
<p>page body</p>
</OuterLayout>

Proven end to end — the page’s head-slotted <link> and default-slotted <p> both arrive correctly at InnerLayout, two levels down:

<header><link rel="canonical" href="https://example.com/x"></header><main><p>page body</p></main>

Why <slot> inside a framework component doesn’t work

Section titled “Why <slot> inside a framework component doesn’t work”

<slot> is Astro-template syntax, compiled by the Astro Rust compiler. A React/Preact/Vue component is compiled by its own framework, which has no idea what an Astro <slot> means — so writing one inside a .tsx/.jsx file does not forward anything; it renders as a literal, inert <slot> DOM element (the browser’s native, unstyled <slot> tag), and the markup you actually passed is dropped somewhere else entirely. Proven with a real build:

// src/components/ReactBoxWithSlot.tsx — a common AI-generated mistake
export default function ReactBoxWithSlot() {
return <div class="react-box"><slot /></div>;
}
src/components/ReactBoxWithSlotHost.astro
---
import ReactBoxWithSlot from './ReactBoxWithSlot.tsx';
---
<ReactBoxWithSlot client:load>
<p>This will NOT appear</p>
</ReactBoxWithSlot>

The real built markup (island bootstrap scripts elided) — the passed <p> ends up inert inside a <template>, never inside the component’s rendered <div>, and <slot></slot> is left as a literal, empty native element:

<astro-island ... component-url=".../ReactBoxWithSlot.tsx" ssr client="load" ...>
<div class="react-box"><slot></slot></div>
<template data-astro-template><p>This will NOT appear</p></template>
</astro-island>

The correct way for a framework component to receive Astro-passed markup is its own framework’s children mechanism — {children} in React/Preact, <slot /> in Vue’s own SFC compiler (a different, unrelated <slot>), or {$$slots.default?.()} in Svelte:

src/components/ReactBox.tsx
export default function ReactBox({ children }: { children?: any }) {
return <div class="react-box">{children ?? 'no children received'}</div>;
}
src/pages/react-box-host.astro
---
import ReactBox from '../components/ReactBox.tsx';
---
<ReactBox client:load>
<p>Hello from Astro markup</p>
</ReactBox>

Proven — Astro serializes the passed markup into an <astro-slot> wrapper and hands it to the component as its children prop (island bootstrap elided):

<astro-island ... component-url=".../ReactBox.tsx" ssr client="load" ...>
<div class="react-box"><astro-slot><p>Hello from Astro markup</p></astro-slot></div>
</astro-island>

This is what slots are for in practice. A layout is a component that defines the page shell — the <html>, <head>, nav, footer — with a <slot /> for the page body. Every page wraps its content in the layout, so the shell lives in one place.

src/layouts/BaseLayout.astro
---
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title}</title>
<slot name="head" />
</head>
<body>
<nav>…site nav…</nav>
<main><slot /></main>
<footer>…site footer…</footer>
</body>
</html>
src/pages/about.astro
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About">
<link slot="head" rel="canonical" href="https://example.com/about" />
<h1>About us</h1>
<p>Page-specific content in the default slot.</p>
</BaseLayout>
flowchart TB
  layout["BaseLayout: html, head, nav, footer with slots"] --> page["Page fills the default slot with its body"]
  page --> head["Page fills the head slot with meta tags"]
  layout --> out["one composed HTML document"]
  page --> out
  head --> out
A layout is a shell with slots; pages fill the slots

With slots you compose small components into pages the same way you compose functions. Because it is all server-rendered, composition costs nothing at runtime — it is just HTML assembly.

Pattern Shape Example
Wrapper Default slot only Card — a bordered box around whatever body content the parent supplies
Container Default slot holding repeated children Grid whose default slot holds several Cards
Multi-region Several named slots Section with named header and body slots
Shell Named + default slots at the top of the tree A Layout — head, default (page body), and often footer
Relay A slot re-offered to a nested component A layout wrapping another layout — proven above with <slot name="x" slot="x" />
Render prop Function passed as slot content A component exposing computed data via Astro.slots.render(name, args) — proven above

You keep each piece focused and let the parent decide what goes inside.

  • Does a .tsx/.jsx framework component use <slot /> expecting Astro-passed children to appear there? Proven above: it renders as a literal, empty native <slot> element, and the actual content is dropped into an inert <template> instead — the fix is the framework’s own children mechanism.
  • Does a layout that wraps another layout just render <slot />/<slot name="x" /> for its own body, silently dropping any other named slot the outer caller passed? Every named slot the wrapping layout doesn’t explicitly relay (<slot name="x" slot="x" />) is lost.
  • Is Astro.slots.has('x') checked before rendering a wrapper element for that slot, or does the component always render the wrapper (an empty <aside></aside>) even when nothing was passed?
  • Does a scoped-slot function actually receive the arguments the component passes to Astro.slots.render(name, args), or is the function-as-child pattern used just for “pass some markup” (a plain default slot would be simpler and clearer)?

🐛 Spot the bug

src/components/UserCard.tsx
export default function UserCard({ user }: { user: { name: string } }) {
return (
<div class="card">
<h2>{user.name}</h2>
<slot />
</div>
);
}
Show the answer

UserCard is a .tsx file, compiled by the framework’s own JSX, not by Astro — a literal <slot /> here is not Astro’s slot mechanism at all. Proven earlier in this lesson: writing <slot /> inside a framework component renders as an inert, empty native <slot> HTML element, and whatever markup the caller placed between <UserCard>...</UserCard> tags is serialized into a <template> (or <astro-slot>, depending on how it’s consumed) instead of appearing inside this <div> at all. The fix is to accept children as a prop and render {children} where the <slot /> currently sits — that’s the mechanism framework components actually use to receive markup Astro passed to them.

A slot is a hole filled at the call site, not a prop resolved inside the component — the parent decides the markup, the component decides where it goes (and, with Astro.slots.has, whether to build any structure around it at all). That contract only exists inside Astro’s own compiler: cross the boundary into a framework component and <slot> stops meaning anything — proven above, it’s just an inert HTML element there, and the framework’s own children mechanism takes over.

Plain default slot Named slots Scoped slot (Astro.slots.render)
Parent controls The markup Which markup goes where The markup, and receives data back from the component
Component controls Nothing about the content Structure around each named region What data to expose to the parent’s function
Complexity Lowest Medium — must document each slot name Highest — parent writes a function, not just markup
Best for Wrapping a single region (a Card body) A layout shell with several regions A component that computes something the parent needs to render with
  • “<slot /> works the same inside a React/Preact/Vue component as it does in .astro files.” Proven above: inside a framework component it’s just a literal, inert HTML <slot> element — the framework’s own children (or equivalent) mechanism is what actually receives Astro-passed markup.
  • “A layout wrapping another layout automatically forwards every slot the outer caller passed.” Proven above: each named slot must be explicitly relayed with <slot name="x" slot="x" />, or it never reaches the inner layout.
  • “Astro.slots.has('x') is the same as checking a prop.” It reflects whether markup was passed for that slot name at the call site, independent of any props — a component can have props but no slotted content, or vice versa.
  • “Fallback content inside <slot> only works for text.” It’s ordinary markup — any elements placed between <slot> and </slot> render whenever the parent passes nothing, proven above with a fallback button.
src/components/FallbackButton.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect } from 'vitest';
import FallbackButton from './FallbackButton.astro';
describe('slot fallback content', () => {
it('renders the fallback when no children are passed', async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(FallbackButton);
expect(html).toContain('Click me');
});
it('renders the passed children instead of the fallback', async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(FallbackButton, {
slots: { default: 'Save' },
});
expect(html).toContain('Save');
expect(html).not.toContain('Click me');
});
});
How do you provide more than one insertion point in a component?
Proven with the Container API, what renders when <FallbackButton /> is used with no children at all?
---
// src/components/FallbackButton.astro
---
<button><slot>Click me</slot></button>
How does a component check whether a slot received content?
What is the primary use of slots in a real Astro site?
Proven with a real build, what happens when Astro markup is passed as children to this component?
// src/components/ReactBoxWithSlot.tsx
export default function ReactBoxWithSlot() {
  return <div class="react-box"><slot /></div>;
}
A page using OuterLayout also passes a head-named slot. What happens to it, proven above?
---
// src/layouts/OuterLayout.astro
import InnerLayout from './InnerLayout.astro';
---
<InnerLayout>
  <slot />
</InnerLayout>
What does Astro.slots.render('default', ['scoped-arg']) enable, proven in this lesson?