Skill says use Python script on file, but user provided inline text. User instructions highest priority — compress inline, return directly.

---
name: astro
description: Astro+React island projects. Enforces hydration directive selection, provider context across island boundaries, SSR-safe react-query, SEO-preserving patterns. Triggers on *.astro, astro.config.*, React islands, @astrojs/react config edits.
---

# Astro + React Islands — Production Rules

Battle-tested rules. No empty HTML to crawlers. No SSR crash. No silent context break across island boundaries.

## The single biggest gotcha

**Astro slots serialize React `children` as HTML before parent renders.** React context (`Context.Provider`, `QueryClientProvider`, `ThemeProvider`, etc.) declared in parent island does NOT reach slot children. Isolated React tree. No provider access.

```astro
<!-- BROKEN: providers in <ProviderWrapper> never reach <Inner /> -->
<ProviderWrapper client:load>
  <Inner />
</ProviderWrapper>
```

### Primary fix: self-wrap providers inside the island component

Universal, version-agnostic. Each top-level island wraps own providers internally. Eliminates slot boundary. Single React tree per page.

```tsx
// features/home/HomePage.tsx
import { HydratedIsland } from '@/components/HydratedIsland';
import type { DehydratedState } from '@tanstack/react-query';

function Inner(props: HomePageProps) { /* ...real content... */ }

export function HomePage(props: HomePageProps & { dehydratedState?: DehydratedState }) {
  const { dehydratedState, ...rest } = props;
  return (
    <HydratedIsland dehydratedState={dehydratedState}>
      <Inner {...rest} />
    </HydratedIsland>
  );
}
```

```astro
---
// pages/index.astro — direct island, no Astro slot
import { HomePage } from '@/features/home/HomePage';
---
<HomePage client:load dehydratedState={dehydratedState} {...props} />
```

Apply to every public page top-level feature. `<HydratedIsland>` wrapper is React-internal — Astro never sees it as slot parent.

Deep export indirection (e.g. `features/stores/index.ts`): convert index to `index.tsx`, compose wrapper there:

```tsx
// features/stores/index.tsx
import { Stores as StoresInner, type StoresProps } from './Stores';
import { HydratedIsland } from '@/components/HydratedIsland';
import type { DehydratedState } from '@tanstack/react-query';

export type { StoresProps } from './Stores';

export function Stores(props: StoresProps & { dehydratedState?: DehydratedState }) {
  const { dehydratedState, ...rest } = props;
  return (
    <HydratedIsland dehydratedState={dehydratedState}>
      <StoresInner {...rest} />
    </HydratedIsland>
  );
}
```

### Legacy/optional: `experimentalReactChildren: true`

Older `@astrojs/react` v3/early v4 had flag for VDOM propagation through slots. **In `@astrojs/react` v5 + Astro 6 flag is silent no-op** — children still serialize as HTML. Empirically: slot pattern + flag crashed with "No QueryClient set" until refactored to self-wrap.

Older stack + flag works: fine. **Don't rely on it for new projects.** Self-wrap works every version, never silently breaks.

```ts
// astro.config.ts — keep this for older stacks ONLY; harmless no-op on v5+
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';

export default defineConfig({
  integrations: [react({ experimentalReactChildren: true })],
});
```

## Hard rules

1. **Self-wrap providers inside island component** (see "Primary fix"). Never pass children through Astro slot expecting context flow — slot serializes as HTML. Self-wrap = universal, version-agnostic.
2. **Never `client:only="react"` on public/SEO pages.** Skips SSR → empty crawler HTML → SEO dead. Reserve for auth-only routes (admin, dashboards).
3. **One root provider island per page.** All page-level providers in single `<HydratedIsland>`. Never nest provider islands.
4. **One `QueryClient` singleton in browser, fresh per request on server.** Server reuse leaks data on Cloudflare/Edge.
5. **Never browser-only APIs at module top level in server-rendered components.** `window`, `document`, `localStorage`, `navigator` — gate behind `typeof window !== 'undefined'` or `useEffect`.
6. **Data-driven pages: react-query SSR dehydrate pattern** — server prefetch, dehydrate, hydrate on client. No waterfall, full SEO HTML.
7. **CI must fail if public page uses `client:only="react"`.** Add audit script.

## Hydration directive decision table

| Directive | When to use | SSR HTML? |
|---|---|---|
| `client:load` | Above-fold, LCP-critical, immediate interact | yes |
| `client:visible` | Below-fold, defer until visible | yes |
| `client:idle` | Non-critical interactive | yes |
| `client:media="(...)"` | Conditional on media query | yes |
| `client:only="react"` | Private routes only — no SSR, no SEO | NO |

**Rule:** crawler-visible content must SSR. Only `client:only` skips SSR.

## Provider island pattern

Canonical wrapper, all page-level providers in one React tree. Place at `src/components/HydratedIsland.tsx`:

```tsx
import {
  QueryClientProvider,
  HydrationBoundary,
  type DehydratedState,
} from '@tanstack/react-query';
import { type ReactNode } from 'react';
import { getBrowserQueryClient, createBrowserQueryClient } from '@/lib/query/client';

export interface HydratedIslandProps {
  dehydratedState?: DehydratedState;
  children: ReactNode;
}

export function HydratedIsland({ dehydratedState, children }: HydratedIslandProps) {
  return (
    <QueryClientProvider
      client={typeof window === 'undefined' ? createBrowserQueryClient() : getBrowserQueryClient()}
    >
      <HydrationBoundary state={dehydratedState}>
        {children}
      </HydrationBoundary>
    </QueryClientProvider>
  );
}
```

**Critical:** `typeof window === 'undefined'` branch. `getBrowserQueryClient` throws on server. Without SSR fallback: page crash → empty body → broken SEO + hydration.

## QueryClient singleton + factory

```ts
// src/lib/query/client.ts
import { QueryClient } from '@tanstack/react-query';

export function createBrowserQueryClient(): QueryClient {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,
        gcTime: 30 * 60_000,
        retry: 1,
        refetchOnWindowFocus: false,
      },
      mutations: { retry: 0 },
    },
  });
}

let _client: QueryClient | undefined;

export function getBrowserQueryClient(): QueryClient {
  if (typeof window === 'undefined') {
    throw new Error('getBrowserQueryClient: server context — use createBrowserQueryClient');
  }
  return (_client ??= createBrowserQueryClient());
}
```

**Edge runtime:** never module-scope QueryClient on server. Cloudflare Workers reuse module instances across requests — singleton leaks cached data between users. Always per-request.

## SSR dehydrate pattern (full SEO + zero waterfall)

Server prefetch → dehydrate → ship in HTML → client `HydrationBoundary` rehydrates instantly. No spinner, no waterfall, full crawler HTML.

```astro
---
// src/pages/index.astro
import { dehydrate } from '@tanstack/react-query';
import { createBrowserQueryClient } from '@/lib/query/client';
import { HydratedIsland } from '@/components/HydratedIsland';
import { HomePage } from '@/features/home/HomePage';
import { fetchUser, fetchFeed } from '@/lib/api';

// Per-request QueryClient — never module-scope on server
const queryClient = createBrowserQueryClient();
await Promise.all([
  queryClient.prefetchQuery({ queryKey: ['user'], queryFn: () => fetchUser(Astro) }),
  queryClient.prefetchQuery({ queryKey: ['feed'], queryFn: () => fetchFeed(Astro) }),
]);
const dehydratedState = dehydrate(queryClient);
---

<HydratedIsland dehydratedState={dehydratedState} client:load>
  <HomePage />
</HydratedIsland>
```

Inside `<HomePage>`:

```tsx
import { useQuery } from '@tanstack/react-query';
export function HomePage() {
  // Data is instantly available from dehydrated state — no spinner.
  const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
  const { data: feed } = useQuery({ queryKey: ['feed'], queryFn: fetchFeed });
  return <Feed user={user} items={feed} />;
}
```

Helper for repeated use:

```ts
// src/lib/query/ssr.ts
import { dehydrate, type DehydratedState } from '@tanstack/react-query';
import { createBrowserQueryClient } from './client';

export async function dehydrateForIsland(
  prefetchers: ((qc: ReturnType<typeof createBrowserQueryClient>) => Promise<unknown>)[],
): Promise<{ dehydratedState: DehydratedState }> {
  const qc = createBrowserQueryClient();
  await Promise.all(prefetchers.map((p) => p(qc)));
  return { dehydratedState: dehydrate(qc) };
}
```

## Common pitfalls (anti-pattern → fix)

| Pitfall | Symptom | Fix |
|---|---|---|
| Wrap island around `useQuery` children without `experimentalReactChildren` | "No QueryClient set" in prod, empty SSR body | Enable flag |
| Use `client:only` to dodge SSR crash | SEO dead, blank paint | Fix SSR error (usually browser-only API at module top) |
| `window`/`document` at module top | SSR `ReferenceError` | Gate `typeof window !== 'undefined'` or `useEffect` |
| Nested `<HydratedIsland>` | Multiple QueryClients, broken cache, double providers | One provider island per page |
| Module-scope QueryClient on server | Data leaks on Edge | Per-request `new QueryClient()` in handler |
| `getBrowserQueryClient()` during SSR | Silent prod crash, blank page | Branch `typeof window`; `createBrowserQueryClient()` on server |
| Forget await before `dehydrate()` | Empty state, client refetches | `await Promise.all([...])` before dehydrate |

## CI hardening

### Audit script — fail build if `client:only` on public page

```bash
#!/usr/bin/env bash
# scripts/audit-islands.sh
set -e
BAD=$(grep -rln 'client:only="react"' src/pages/ \
  --exclude-dir=admin --exclude-dir=vendor --exclude-dir=account 2>/dev/null || true)
if [ -n "$BAD" ]; then
  echo "ERROR: client:only on public SEO pages:"
  echo "$BAD"
  exit 1
fi
echo "OK: no client:only on public pages"
```

Wire into `package.json`:
```json
{
  "scripts": {
    "audit:islands": "bash scripts/audit-islands.sh",
    "prebuild": "pnpm audit:islands"
  }
}
```

### ESLint guard (optional, custom rule)

Forbid `useQuery` not reachable from `<HydratedIsland>`. Easier: grep pre-commit check.

### E2E SEO test

Test public routes return non-empty SSR HTML:

```ts
test('homepage SSR is non-empty', async ({ request }) => {
  const res = await request.get('/');
  const body = await res.text();
  expect(body.length).toBeGreaterThan(10_000);
  expect(body).toContain('<!DOCTYPE html>');
  expect(body).toMatch(/<h1[^>]*>/);
});
```

## Cloudflare Workers / Edge specific

- Per-request QueryClient mandatory (module scope reused = data leak).
- `cloudflare:workers` env access only inside request handlers, never module-top.
- Astro `@astrojs/cloudflare` adapter: `output: 'server'` (or `'hybrid'`), `runtime.mode: 'local'` for dev.
- `export const prerender = true` = cheapest for marketing pages — no per-user data.

## Decision flow

```
Building a new page?
├─ Public + SEO matters?
│  ├─ Pure static content? → no React island, plain Astro + `prerender = true`
│  ├─ Above-fold interactive? → <HydratedIsland client:load> + SSR dehydrate
│  └─ Below-fold interactive? → <HydratedIsland client:visible> + SSR dehydrate
└─ Private (admin / dashboard)?
   └─ <HydratedIsland client:only="react"> (skip SSR, save server work)
```

## Performance + SSR strategy (apply in this order)

Rules compound. Each level: less server CPU, better LCP.

### 1. Prerender static pages

Pages with no per-user data, no auth rendering, no per-request `cf` lookups, no query-param personalization:

```astro
---
export const prerender = true;
---
```

Candidates: `/about`, `/contact`, `/privacy`, `/terms`, `/faq`, `/help`, `/legal/*`, marketing, 404. Skip if reads `Astro.locals.user`, `Astro.cookies`, `Astro.request.cf`, or per-request fetch.

Wins: zero CPU, edge-cached HTML, instant TTFB.

### 2. Dashboards: `client:only="react"`

Auth-gated routes (admin, vendor, profile, settings, dashboards): top-level island → `client:only="react"`. Noindex — SSR wasted CPU.

```astro
<DashboardIsland client:only="react" {...props} />
```

Trade-off: blank flash before JS boot. Acceptable for logged-in users.

Caveats:
- Self-wrap providers must be in place (client mount needs QueryClient).
- Props must be JSON-serializable (no functions, no class instances). `Date` OK; use `Astro.props.date.toISOString()` if unsure.
- Auth check + redirect in Astro frontmatter, not React island.

### 3. Below-fold islands: `client:visible` (or `client:idle`)

Multiple islands on public page: LCP/above-fold → `client:load`. Below fold → `client:visible`. Non-critical (settings, accessibility, command palettes) → `client:idle`.

```astro
<HeroIsland client:load {...heroProps} />
<RecommendationsIsland client:visible {...recProps} />
<CommandPalette client:idle />
```

Wins: smaller initial JS, faster TTI, better CWV.

### 4. Client-fetch → frontmatter prefetch (SSR dehydrate)

`useQuery` on public page should NOT browser-fetch if data needed for SEO/above-fold. Migrate to server-side prefetch + dehydrate.

**Before** (client waterfall, empty SSR HTML):

```tsx
// Feed.tsx — runs only after JS boots
const { data } = useQuery({ queryKey: ['feed'], queryFn: fetchFeed });
```

**After** (server prefetch, full HTML, instant cache hit):

```astro
---
// pages/index.astro
import { dehydrate } from '@tanstack/react-query';
import { createBrowserQueryClient } from '@/lib/query/client';
import { fetchFeed } from '@/lib/api';

const qc = createBrowserQueryClient();
await qc.prefetchQuery({ queryKey: ['feed'], queryFn: () => fetchFeed(Astro) });
const dehydratedState = dehydrate(qc);
---

<HomePage client:load dehydratedState={dehydratedState} />
```

```tsx
// HomePage.tsx — same hook, instant data from cache
const { data } = useQuery({ queryKey: ['feed'], queryFn: fetchFeed });
```

**Don't migrate:** auth-specific (cart, profile, wishlist), admin/vendor, search/filter (URL-driven), `client:only` islands.

**Do migrate:** homepage feed, deal detail, store list, business page detail, public loyalty/rewards.

### Routing matrix

| Route group | Directive | Prerender? | Prefetch? |
|---|---|---|---|
| Static informational (`/about`, `/contact`, `/legal/*`) | `client:idle` (if any island) | yes | n/a |
| Marketing landing | `client:load` hero, `client:visible` below | yes | optional |
| Public content (`/`, `/deals/*`, `/stores/*`) | `client:load` hero, `client:visible` below | no | yes (frontmatter) |
| Search / filter (`/search?q=`) | `client:load` | no | optional (URL-driven) |
| Auth-gated (`/loyalty`, `/wishlist`) | `client:visible` or `client:only` | no | n/a |
| Dashboards (`/admin/**`, `/vendor/**`, `/profile/**`, `/settings/**`) | `client:only="react"` | no | n/a |

## TL;DR

1. **Self-wrap providers inside island** — never rely on Astro slots for context propagation.
2. SSR-safe QueryClient (`typeof window === 'undefined'` fallback in wrapper).
3. Never `client:only` on public/SEO pages.
4. Per-request QueryClient on server, singleton in browser.
5. Dehydrate pattern for data-driven pages.
6. Audit script in CI to enforce.
7. `experimentalReactChildren: true` legacy/optional — no-op on modern stacks. Don't depend on it.

## Learned Rules

### astro-flag-empirical-verify | fired:1 | 2026-04-26
recommended `experimentalReactChildren: true` without testing on `@astrojs/react` v5 → no-op, shipped broken homepage. Flag semantics change silently across versions.
Prevent: smoke test any Astro/@astrojs/react flag on target version, curl body bytes. Default to self-wrap.

### ssr-verify-body-bytes | fired:1 | 2026-04-26
verification used HTTP 200 as success → 0-byte body shipped to prod. Cloudflare returns 200 even when worker throws after headers sent.
Prevent: SSR smoke test = `curl -s URL | wc -c` > threshold (50KB+ content pages). 200 alone not success.