src/skills/web-meta-framework-astro/SKILL.md
Astro content-first framework - islands architecture, content collections, file-based routing, SSR/SSG hybrid rendering, View Transitions, server islands, multi-framework component support
npx skillsauth add agents-inc/skills web-meta-framework-astroInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick Guide: Astro renders pages to static HTML by default with zero client-side JavaScript. Use
.astrocomponents for all static content, addclient:*directives only on interactive framework components (React/Vue/Svelte). Use content collections for type-safe structured content. Choose between static (default) and on-demand (SSR) rendering per-page withexport const prerender.
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST keep pages static by default - only add export const prerender = false when the page genuinely needs request-time data)
(You MUST use client:* directives on framework components that need interactivity - without a directive, components render to static HTML with zero JavaScript)
(You MUST define content collections in src/content.config.ts with Zod schemas for type-safe frontmatter)
(You MUST use <ClientRouter /> from astro:transitions for View Transitions - the old <ViewTransitions /> component is removed in Astro 6)
(You MUST install a server adapter (@astrojs/node, @astrojs/vercel, etc.) before using on-demand rendering)
(You MUST use getStaticPaths() for dynamic routes in static mode - it is not needed for on-demand (SSR) routes)
</critical_requirements>
Auto-detection: Astro, .astro files, astro.config, islands architecture, client:load, client:visible, client:idle, client:only, client:media, server:defer, content collections, defineCollection, defineLiveCollection, getCollection, getLiveCollection, getEntry, getLiveEntry, render, astro:content, astro:transitions, ClientRouter, getStaticPaths, Astro.props, Astro.params, Astro.cookies, Astro.redirect, prerender, astro add, @astrojs/react, @astrojs/vue, @astrojs/svelte, Starlight
When to use:
When NOT to use:
Key patterns covered:
Detailed Resources:
Core patterns:
Astro is a content-first web framework that ships zero JavaScript by default. It pioneered the islands architecture where most of the page is fast static HTML, with small interactive "islands" of JavaScript hydrated only where needed.
Core principles:
src/pages/ directory structure maps directly to URLsWhen to use Astro:
When NOT to use Astro:
Astro components (.astro files) have two parts: a frontmatter script block (between --- fences) and an HTML template.
---
// Component Script (frontmatter) - runs on the server only
import Layout from "../layouts/Layout.astro";
import { getCollection } from "astro:content";
// Props accessed via Astro.props
interface Props {
title: string;
description?: string;
}
const { title, description = "Default description" } = Astro.props;
// Server-side data fetching
const posts = await getCollection("blog");
---
<!-- Component Template - HTML with expressions -->
<Layout title={title}>
<h1>{title}</h1>
<p>{description}</p>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
</li>
))}
</ul>
</Layout>
<style>
/* Scoped to this component by default */
h1 {
color: navy;
font-size: 2rem;
}
</style>
Why good: Frontmatter runs server-only (no JavaScript shipped), type-safe props with interface, scoped styles prevent leakage, expressions use JSX-like syntax
Slots allow parent components to inject content into child component templates.
---
// src/components/Card.astro
interface Props {
title: string;
}
const { title } = Astro.props;
---
<article class="card">
<header>
<slot name="header">
<h2>{title}</h2>
</slot>
</header>
<div class="body">
<slot /> <!-- Default slot -->
</div>
<footer>
<slot name="footer">
<p>Default footer</p>
</slot>
</footer>
</article>
---
// Usage in a page
import Card from "../components/Card.astro";
---
<Card title="My Card">
<span slot="header"><h2>Custom Header</h2></span>
<p>This goes in the default slot.</p>
<div slot="footer">
<a href="/more">Read more</a>
</div>
</Card>
Why good: Named slots provide flexible composition, fallback content renders when no slot content is provided, matches Web Component slot semantics
Layouts are Astro components that wrap page content with shared UI (header, footer, navigation).
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
description?: string;
}
const { title, description = "My Astro Site" } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<title>{title}</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
<a href="/about">About</a>
</nav>
<main>
<slot />
</main>
<footer>
<p>© {new Date().getFullYear()} My Site</p>
</footer>
</body>
</html>
---
// src/pages/about.astro
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout title="About Us" description="Learn about our team">
<h1>About Us</h1>
<p>We build great things with Astro.</p>
</BaseLayout>
Why good: Shared layout reduces duplication, metadata props ensure SEO per-page, slot injects page content
By default, framework components (React, Vue, Svelte) render to static HTML. Add a client:* directive to hydrate them as interactive islands.
| Directive | When it hydrates | Use for |
| ---------------- | ------------------------------------ | ---------------------------- |
| client:load | Immediately on page load | Critical interactive UI |
| client:idle | When browser is idle | Lower-priority interactivity |
| client:visible | When component scrolls into viewport | Below-the-fold content |
| client:media | When media query matches | Responsive interactivity |
| client:only | Skips server render, client-only | Browser-dependent components |
---
// src/pages/index.astro
import Header from "../components/Header.astro"; // Static - no JS
import SearchBar from "../components/SearchBar"; // React component
import Newsletter from "../components/Newsletter"; // React component
import Comments from "../components/Comments"; // React component
import Analytics from "../components/Analytics"; // React component
---
<Header />
<!-- Hydrate immediately - user interacts with search right away -->
<SearchBar client:load />
<!-- Hydrate when browser is idle - not urgent -->
<Newsletter client:idle />
<!-- Hydrate only when scrolled into view -->
<Comments client:visible />
<!-- Hydrate only on mobile screens -->
<Analytics client:media="(max-width: 768px)" />
Why good: Only interactive components ship JavaScript, hydration is deferred to optimal timing, static components render as zero-JS HTML
Server islands defer rendering of dynamic content on the server, allowing static shell to cache while dynamic parts render per-request.
---
// src/pages/product.astro
import ProductInfo from "../components/ProductInfo.astro"; // Static
import UserReviews from "../components/UserReviews.astro"; // Dynamic
---
<!-- Static product info - cached -->
<ProductInfo product={product} />
<!-- Server island - rendered per-request, placeholder shown while loading -->
<UserReviews server:defer>
<div slot="fallback">Loading reviews...</div>
</UserReviews>
Why good: Static page shell caches and serves instantly, dynamic portions render per-request without blocking the page, fallback content prevents layout shift
Content collections provide type-safe management of structured content (blog posts, docs, product data).
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob, file } 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(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
const authors = defineCollection({
loader: file("src/data/authors.json"),
schema: z.object({
name: z.string(),
bio: z.string(),
avatar: z.string(),
}),
});
export const collections = { blog, authors };
---
// src/pages/blog/index.astro
import { getCollection } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
const allPosts = await getCollection("blog", ({ data }) => {
return data.draft !== true; // Filter out drafts
});
// Sort by date descending
const sortedPosts = allPosts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<BaseLayout title="Blog">
<h1>Blog</h1>
<ul>
{sortedPosts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>
<h2>{post.data.title}</h2>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
</a>
</li>
))}
</ul>
</BaseLayout>
---
// src/pages/blog/[id].astro
import { getCollection, getEntry, render } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BaseLayout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
<Content />
</article>
</BaseLayout>
Why good: Zod schemas validate frontmatter at build time, TypeScript types are auto-generated, querying with filters is type-safe, render() converts Markdown to a component
For data that changes frequently and needs to be fresh on every request, use defineLiveCollection in a separate src/live.config.ts file (not src/content.config.ts). Query with getLiveCollection() and getLiveEntry() instead of getCollection(). Requires SSR (prerender = false). See examples/content.md for full examples.
The src/pages/ directory structure directly maps to URL paths.
src/pages/
├── index.astro → /
├── about.astro → /about
├── blog/
│ ├── index.astro → /blog
│ └── [id].astro → /blog/:id (dynamic)
├── docs/
│ └── [...slug].astro → /docs/* (rest parameter)
└── _helpers.ts → excluded (underscore prefix)
---
// src/pages/tags/[tag].astro
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog");
const tags = [...new Set(posts.flatMap((post) => post.data.tags))];
return tags.map((tag) => ({
params: { tag },
props: {
posts: posts.filter((post) => post.data.tags.includes(tag)),
},
}));
}
const { tag } = Astro.params;
const { posts } = Astro.props;
---
<h1>Posts tagged: {tag}</h1>
<ul>
{posts.map((post) => (
<li><a href={`/blog/${post.id}`}>{post.data.title}</a></li>
))}
</ul>
---
// src/pages/docs/[...slug].astro
export async function getStaticPaths() {
return [
{ params: { slug: "getting-started" } },
{ params: { slug: "guides/installation" } },
{ params: { slug: "api/reference" } },
{ params: { slug: undefined } }, // matches /docs
];
}
const { slug } = Astro.params;
---
<h1>Docs: {slug ?? "Index"}</h1>
Why good: File structure mirrors URL structure, dynamic params are type-safe via getStaticPaths, rest parameters handle arbitrary depth
Opt individual pages into server-side rendering while keeping the rest static.
---
// src/pages/dashboard.astro
export const prerender = false; // Render on every request
const cookie = Astro.cookies.get("session");
if (!cookie) {
return Astro.redirect("/login");
}
const user = await getUserFromSession(cookie.value);
---
<h1>Welcome, {user.name}</h1>
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server", // All pages server-rendered by default
adapter: node({ mode: "standalone" }),
});
---
// src/pages/about.astro
export const prerender = true; // Opt this page back to static
---
<h1>About Us</h1>
// src/pages/api/search.ts
export const prerender = false;
import type { APIRoute } from "astro";
export const GET: APIRoute = async ({ url }) => {
const query = url.searchParams.get("q");
if (!query) {
return new Response(JSON.stringify({ error: "Missing query" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const results = await searchDatabase(query);
return new Response(JSON.stringify(results), {
headers: { "Content-Type": "application/json" },
});
};
Why good: Static by default for performance, opt into SSR per-page as needed, API endpoints support full request/response control
Astro provides smooth page transitions using the browser's View Transition API via the <ClientRouter /> component.
---
// src/layouts/BaseLayout.astro
import { ClientRouter } from "astro:transitions";
---
<html lang="en">
<head>
<ClientRouter />
</head>
<body>
<slot />
</body>
</html>
---
// src/pages/blog/[id].astro
---
<!-- Name pairs elements across pages for smooth morphing -->
<img
src={post.data.heroImage}
transition:name={`hero-${post.id}`}
transition:animate="slide"
/>
<h1 transition:name={`title-${post.id}`}>
{post.data.title}
</h1>
<!-- Persist interactive elements across navigations -->
<audio controls transition:persist>
<source src="/music.mp3" type="audio/mp3" />
</audio>
Why good: Smooth transitions between pages without SPA framework, paired elements morph naturally, persistent elements maintain state across navigation
</patterns><red_flags>
High Priority Issues:
client:load to every component - Defeats islands architecture; only hydrate components that need interactivity.astro components for zero-JS static HTMLgetStaticPaths() on dynamic routes in static mode - Build will fail<ViewTransitions /> - Removed in Astro 6; use <ClientRouter /> from astro:transitionsprerender = false - On-demand rendering requires an adapterMedium Priority Issues:
<script> tags instead of frontmatter - Frontmatter runs server-side; use it for data fetchingclient:only when client:load would work - client:only skips SSR, hurting SEOoutput: 'server' for mostly static sites - Default static mode with per-page SSR opt-in is more performantGotchas & Edge Cases:
.astro are scoped by default - Use <style is:global> or :global() selector for global stylesclient:visible uses IntersectionObserver - Component won't hydrate if always off-screenAstro.redirect() only works in on-demand rendered pages - Static pages cannot redirect at request timetransition:persist requires matching transition:name - Elements must have the same name on both pagesAstro.glob() removed in Astro 6 - Use import.meta.glob() insteadz from astro:content removed in Astro 6 - Import z from astro/zod insteadz.string().email() becomes z.email())getStaticPaths() params must be strings - Number params are no longer allowed in Astro 6src/live.config.ts - Not src/content.config.ts (separate config file)import.meta.env values are inlined at build time in Astro 6 - Use process.env for runtime secrets in live collections and SSR codeFor complete anti-patterns with code examples, see reference.md.
</red_flags>
Astro is the top-level framework. It handles routing, rendering, and content management. UI framework components (React, Vue, Svelte) are used as islands within Astro pages.
Adding framework support:
# Add framework support via CLI
npx astro add react
npx astro add vue
# Add multiple integrations at once
npx astro add react sitemap
Key integrations:
@astrojs/mdx for components in MarkdownDeployment adapters:
Does NOT replace:
<critical_reminders>
All code must follow project conventions in CLAUDE.md
(You MUST keep pages static by default - only add export const prerender = false when the page genuinely needs request-time data)
(You MUST use client:* directives on framework components that need interactivity - without a directive, components render to static HTML with zero JavaScript)
(You MUST define content collections in src/content.config.ts with Zod schemas for type-safe frontmatter)
(You MUST use <ClientRouter /> from astro:transitions for View Transitions - the old <ViewTransitions /> component is removed in Astro 6)
(You MUST install a server adapter (@astrojs/node, @astrojs/vercel, etc.) before using on-demand rendering)
(You MUST use getStaticPaths() for dynamic routes in static mode - it is not needed for on-demand (SSR) routes)
Failure to follow these rules will ship unnecessary JavaScript, break builds, cause missing pages, or produce type errors.
</critical_reminders>
development
Material Design component library for Vue 3
development
VitePress 1.x — Vue-powered static site generator for documentation sites, built on Vite
tools
Docusaurus 3.x documentation framework — site configuration, docs/blog plugins, sidebars, versioning, MDX, swizzling, and deployment
development
TanStack Form patterns - useForm, form.Field, validators, arrays, linked fields, createFormHook, type safety