Skip to content

Islands & Components

FieldValue
TypeAgent Reference
Source~/.copilot/agents/_refs/astro/islands-and-components.md
DescriptionNot specified

Source Content

Islands & Components

Island Hydration Directives

The default is zero JavaScript. Add a client:* directive only when the component genuinely needs client-side interactivity. Every directive is a deliberate decision.

DirectiveWhen the JS loadsWhen to use
(none)NeverStatic HTML — no interactivity needed
client:loadImmediately on page loadImmediately interactive above-the-fold content
client:idleWhen the browser is idleNon-critical interactive content
client:visibleWhen the component enters the viewportBelow-the-fold interactive content
client:media="(query)"When a CSS media query matchesInteraction only needed at certain viewport sizes
client:only="react"Client-only renderComponents that cannot SSR (charts, maps, 3rd-party widgets)

Decision rule: Start with no directive. Add the least aggressive one that solves the actual need.

---
import { CaseList } from '../components/CaseList'
import { FilterPanel } from '../components/FilterPanel'
import { ActivityChart } from '../components/ActivityChart'
import { NotificationBell } from '../components/NotificationBell'
---
<!-- Static — no hydration needed -->
<CaseList cases={cases} />
<!-- Immediately interactive — user can click right away -->
<NotificationBell client:load />
<!-- Below the fold — wait until visible -->
<FilterPanel client:visible />
<!-- Non-critical — hydrate during idle time -->
<ActivityChart client:idle />
<!-- Can't SSR — e.g., depends on window -->
<MapWidget client:only="react" />

Passing Data to Islands

Islands receive props from Astro frontmatter. Props must be serializable (JSON-safe). No functions, no class instances, no non-serializable objects.

---
import { CaseDetail } from '../components/CaseDetail'
import { getCase } from '../lib/db'
const { id } = Astro.params
const case_ = await getCase(id)
// Transform to a serializable shape before passing
const caseProps = {
id: case_.id,
title: case_.title,
type: case_.type,
filedAt: case_.filedAt.toISOString(), // Dates → strings
// clientId only, not the full client object
clientId: case_.client.id,
clientName: case_.client.name,
}
---
<CaseDetail client:load case={caseProps} />

Rules:

  • Dates are always strings (ISO 8601) at the Astro→React boundary.
  • Never pass database model instances as props — serialize to a plain object first.
  • If the island needs more data, it fetches via TanStack Query using the IDs passed as props.

Smart vs Dumb Islands

Apply the same dumb/smart split from the React agent — but the “smart” layer can be Astro fetching + React displaying:

---
// Astro does the SSR data fetch (smart)
const cases = await db.cases.findMany({ where: { userId: locals.user.id } })
---
<!-- React island is dumb — receives pre-fetched data -->
<CaseTable client:load cases={cases.map(serialize)} />

For client-side dynamic data (filters, pagination, real-time), the React island uses TanStack Query:

// CaseTable.tsx — starts from SSR data, then fetches dynamically
interface CaseTableProps {
initialCases: SerializedCase[] // pre-fetched by Astro
}
function CaseTable({ initialCases }: CaseTableProps) {
const [filters, setFilters] = useState({ page: 1, q: '' })
const { data: cases } = useQuery({
queryKey: ['cases', filters],
queryFn: () => fetchCases(filters),
initialData: filters.page === 1 && !filters.q ? initialCases : undefined,
})
// ...
}

Sharing State Between Islands

Islands are isolated by default. For state shared between multiple islands on a page, use one of:

  1. Nano Stores (nanostores) — tiny reactive store, works across frameworks:
src/stores/notifications.ts
import { atom } from 'nanostores'
export const notificationCount = atom(0)
// In any island
import { useStore } from '@nanostores/react'
import { notificationCount } from '../stores/notifications'
function NotificationBell() {
const count = useStore(notificationCount)
// ...
}
  1. URL params — for navigational state, use window.location or TanStack Router if the app is SPA-mode.

  2. Custom events — for simple cross-island communication:

// Dispatch from island A
window.dispatchEvent(new CustomEvent('case-created', { detail: { id: newCase.id } }))
// Listen in island B
useEffect(() => {
const handler = (e: CustomEvent) => refetch()
window.addEventListener('case-created', handler)
return () => window.removeEventListener('case-created', handler)
}, [])

Avoid Zustand for cross-island state — it hydrates per island. Use Nano Stores.

Astro Components

.astro files are not React. They’re server-only templates. Write them like HTML with a frontmatter script block:

---
// This runs on the server only — no browser APIs
import type { GetStaticPaths } from 'astro'
import { Layout } from '../layouts/Layout.astro'
import { CaseDetail } from '../components/CaseDetail'
import { getCaseById } from '../lib/cases'
import { z } from 'zod'
interface Props {
caseId: string
}
const { caseId } = Astro.params
// Validate params
const result = z.string().uuid().safeParse(caseId)
if (!result.success) {
return Astro.redirect('/cases')
}
const case_ = await getCaseById(result.data, Astro.locals.user.id)
if (!case_) {
return Astro.redirect('/cases')
}
---
<Layout title={case_.title}>
<main>
<CaseDetail client:load case={serialize(case_)} />
</main>
</Layout>

Astro component rules:

  • No hooks, no useState, no useEffect — those are React-only.
  • --- frontmatter is server-only. Don’t put API keys in client-visible scope.
  • Use Astro.locals for user/session data set by middleware.
  • Use Astro.redirect() for navigation — it returns a Response.
  • <slot /> is how Astro layouts accept children (equivalent to React children).