astro · git:20260914.a9571da · 2026-09-14 · sha256 79b8f6915c23fae5

astro git:20260914.a9571daA

Immutable. This exact content is served forever at /api/v1/blob/79b8f6915c23fae5.

---
name: astro
description: Enforce Astro 7 static-first conventions. Use when editing .astro files or when the user mentions Astro, islands, hydration, client directives, server:defer, content collections, or getCollection. Ships zero JS by default and picks the lightest client directive that works — client:visible or client:idle ahead of client:load, getCollection ahead of hand-rolled glob imports.
paths:
  - "**/*.astro"
---

> Targets Astro 7 · verified 2026-09 (latest 7.3.2).

This skill enforces Astro static-first conventions. The rule: render to static HTML by default, ship JavaScript only where there is real interactivity, and pick the lightest hydration directive that works.

Apply only when the project uses `astro ^7.0` or higher. If `package.json` pins an older major, **STOP** and ask before applying.

## What changed in 7 (and 6)

Client directives, content collections and server islands are unchanged — the rules below carry over. What did change:

| Version | Change | What it means |
|---|---|---|
| 7 | Rust compiler replaces the Go one | Every non-void element needs a closing tag. Invalid markup is no longer auto-corrected — it errors |
| 7 | `compressHTML` defaults to `'jsx'`, not `true` | Whitespace between inline elements is stripped by JSX rules. Check spacing-sensitive layouts after upgrading |
| 7 | Markdown runs on Sätteri instead of remark/rehype | Drop `@astrojs/markdown-remark`, or install it explicitly if you depend on specific plugins |
| 7 | `src/fetch.ts` is a reserved filename | Rename it, or point `fetchFile` elsewhere |
| 7 | Vite 8 | Re-check Vite plugins and config |
| 6 | Node 18 and 20 dropped | Requires Node 22.12+ |
| 6 | The v2-era Content Collections API is gone | Content Layer `loader` API only; config must live at `src/content.config.ts` |
| 6 | `import.meta.env` values are always inlined | Automatic type coercion is gone — parse strings yourself |

## Core principles

- **Default to zero JS**. Astro components render to HTML at build (or request) time and ship no JS. Adding `client:*` to a component opts into hydration—do it deliberately.
- **Pick the lightest `client:*` that works**. The directive is a hydration trigger, not a rendering choice.
- **Server islands (`server:defer`)** for personalized content within an otherwise static page. Use this instead of switching the whole page to SSR. Both `server:defer` and `prerender = false` need an adapter; if `astro.config.*` has none, **STOP** and ask before adding one.
- **Content Collections** for typed markdown/MDX content. Do not hand-roll glob imports for blogs/docs.

## client:* hierarchy

Pick the first directive that fits, top to bottom:

| Directive | When to use | JS cost |
|---|---|---|
| (none) | Pure presentational HTML/CSS | 0 |
| `client:visible` | Interactive but below the fold (footer widgets, late-page forms) | Loaded on viewport intersection |
| `client:idle` | Interactive but not critical (search, login) | Loaded after page idle |
| `client:media="(...)"` | Only on certain viewports (mobile menu) | Loaded conditionally |
| `client:load` | Above-the-fold interactive (live counter, top-nav search) | Loaded eagerly, blocks |
| `client:only="<framework>"` | Component cannot SSR (relies on `window` at render time) | Skips SSR entirely |

`client:load` is the heaviest. Default to `client:visible` or `client:idle`, escalate only when there is a measurable delay the user notices.

## Forbidden patterns

- `client:load` on a component with no `useState` / no event handlers / no live data. If it is presentational, drop the directive.
- `client:load` on every interactive component when `client:visible` would do.
- Wrapping the entire page in a single `<Layout client:load>`. Hydrate per-island, not per-page.
- Importing a React/Vue/Svelte component into `.astro` without realizing the hydration cost—every framework adds runtime weight.
- Server-side data fetching inside an island that re-fetches on every hydration. Move the fetch to the parent `.astro` and pass as prop.
- `Astro.props` mutated inside the component. Treat as immutable.
- `getStaticPaths` returning thousands of paths without pagination or filtering. Build time grows.
- `Astro.glob('../posts/*.md')`. Removed in Astro 6. Use Content Collections (`getCollection()`) for content/markdown with typed schemas, or `Object.values(import.meta.glob('../posts/*.md', { eager: true }))` for other files. `import.meta.glob()` does not return a `Promise`, so drop the `await`.
- `Astro` inside `getStaticPaths()`. Deprecated in Astro 6. Replace `Astro.site` with `import.meta.env.SITE` and delete `Astro.generator`.
- `<ViewTransitions />`. Removed in Astro 6. Import and render `<ClientRouter />` from `astro:transitions` instead.
- Mixing `output: 'static'` with `client:load` on dynamic components that need fresh data per request. Use `server:defer` or set `prerender = false` for that route (both need an adapter — see above).
- A new island / component when the project already has an equivalent under `src/components/`. grep first; reuse or extend if found.

## Server islands

```astro
---
import UserGreeting from '../components/UserGreeting.astro'
---
<html>
  <body>
    <h1>Welcome</h1>
    <UserGreeting server:defer>
      <p slot="fallback">Loading…</p>
    </UserGreeting>
    <main>... static content ...</main>
  </body>
</html>
```

The page ships static HTML immediately; the personalized greeting renders on the server in parallel and streams in. No SSR for the whole page.

## Content Collections

Astro 5+ uses the `loader` API in `src/content.config.ts` (note the new file path—not `src/content/config.ts`).

```ts
// src/content.config.ts
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'

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

export const collections = { blog }
```

```astro
---
import { getCollection } from 'astro:content'
const posts = await getCollection('blog')
---
```

Do not use:
- `Astro.glob('../posts/*.md')` — removed in v6. For non-content files, use `import.meta.glob()` instead.
- `z` from `astro:content` or `astro:schema` — deprecated in v6. Import it from `astro/zod`.
- `type: 'content'` / `type: 'data'` — replaced by the `loader` API.
- `src/content/config.ts` — the file moved to `src/content.config.ts`.

## When you need full SSR

If the page genuinely needs per-request data for the entire layout (signed-in dashboard root), set `export const prerender = false` on that route. This needs an adapter; if the project has none, **STOP** and ask. Reach for `server:defer` first; full SSR only when the entire shell depends on request context.

## When the right hydration is unclear

> Component [X] is interactive [how]. Above the fold? [yes/no]. Critical for first paint? [yes/no]. Recommended directive: [...]. Approve?

## Verification (grep after every .astro change)

```bash
grep -rnE 'client:load' --include='*.astro' .                   # can it be downgraded to visible/idle?
grep -rnE "Astro\.glob\(" --include='*.astro' --include='*.ts' .   # removed in 6; getCollection or import.meta.glob
grep -rnE '\bz\b[^}]*\}\s*from\s*.astro:content|astro:schema' --include='*.ts' --include='*.mts' --include='*.mjs' --include='*.astro' .   # import z from astro/zod
grep -rnE '\bViewTransitions\b' --include='*.astro' --include='*.ts' .   # removed in 6; use ClientRouter
grep -rlzE 'getStaticPaths([^}]|\n)*Astro\.' --include='*.astro' --include='*.ts' .   # Astro inside getStaticPaths; use import.meta.env.SITE
grep -rnE '<Layout[^>]*client:' --include='*.astro' .           # don't put client:* on Layout
```

Reference: https://docs.astro.build/en/concepts/islands/