
Astro Framework
- 1.8k installs
- 40 repo stars
- Updated April 1, 2026
- delineas/astro-framework-agents
astro-framework is an agent skill for Astro framework specialist for building fast, content-driven websites with islands architecture. Use when creating Astro
About
The astro-framework skill Astro framework specialist for building fast, content-driven websites with islands architecture. Use when creating Astro components, configuring hydration client:load/idle/visible/media , using server:defer server islands , Content Layer API glob/file loaders, live loaders , sessions, astro:env, i18n routing, actions, SSR adapters, view transitions, or integrating React/Vue/Svelte/Solid. Not for full-SPA frameworks Next.js, Remix, SvelteKit . It covers building content-driven websites blogs, docs, marketing sites. Key workflows include implementing islands architecture with selective hydration. This Skill Activate this skill when: - Building content-driven websites blogs, docs, marketing sites - Implementing islands architecture with selective hydration - Using server islands server:defer for deferred server rendering - Creating content collections with the Content Layer API loaders, glob, file - Setting up SSR with adapters Node, Vercel, Netlify, Cloudflare - Building API endpoints and Developers invoke astro-framework when the task matches the triggers and reference files in SKILL.md for grounded, stepwise execution.
- Building content-driven websites blogs, docs, marketing sites
- Implementing islands architecture with selective hydration
- Using server islands server:defer for deferred server rendering
- Creating content collections with the Content Layer API loaders, glob, file
- Setting up SSR with adapters Node, Vercel, Netlify, Cloudflare
Astro Framework by the numbers
- 1,780 all-time installs (skills.sh)
- +26 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #323 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
astro-framework capabilities & compatibility
- Capabilities
- building content driven websites blogs, docs, ma · implementing islands architecture with selective · using server islands server:defer for deferred s · creating content collections with the content la · setting up ssr with adapters node, vercel, netli
- Use cases
- seo · marketing · copywriting
What astro-framework says it does
tags: astro, islands, ssr, ssg, content-collections, content-layer, view-transitions, server-islands, sessions, i18n, actions, astro-env
Senior Astro specialist with deep expertise in islands architecture, content-driven websites, and hybrid rendering strategies.
npx skills add https://github.com/delineas/astro-framework-agents --skill astro-frameworkAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.8k |
|---|---|
| repo stars | ★ 40 |
| Security audit | 2 / 3 scanners passed |
| Last updated | April 1, 2026 |
| Repository | delineas/astro-framework-agents ↗ |
What problem does astro-framework solve for developers using the documented workflows?
Astro framework specialist for building fast, content-driven websites with islands architecture. Use when creating Astro components, configuring hydration client:load/idle/visible/media , using server
Who is it for?
Developers working with astro-framework patterns described in the skill documentation.
Skip if: Skip when docs are empty or the task is outside the skill documented scope.
When should I use this skill?
Use when Astro framework specialist for building fast, content-driven websites with islands architecture. Use when creating Astro components, configuring hydration client:load/idle/visible/
What you get
Actionable astro-framework guidance grounded in SKILL.md workflows and reference files.
- Correct Astro component code
- Content Layer API loader config
By the numbers
- Skill version 2.0.0 targeting Astro 5.x
- Last updated 2026-03-22
Files
Astro Framework Specialist
Senior Astro specialist with deep expertise in islands architecture, content-driven websites, and hybrid rendering strategies.
Role Definition
You are a senior frontend engineer with extensive Astro experience. You specialize in building fast, content-focused websites using Astro's islands architecture, content collections, and hybrid rendering. You understand when to ship JavaScript and when to keep things static.
When to Use This Skill
Activate this skill when:
- Building content-driven websites (blogs, docs, marketing sites)
- Implementing islands architecture with selective hydration
- Using server islands (
server:defer) for deferred server rendering - Creating content collections with the Content Layer API (loaders, glob, file)
- Setting up SSR with adapters (Node, Vercel, Netlify, Cloudflare)
- Building API endpoints and server actions
- Implementing view transitions for SPA-like navigation
- Managing server-side sessions for user state
- Configuring type-safe environment variables with
astro:env - Setting up i18n routing for multilingual sites
- Integrating UI frameworks (React, Vue, Svelte, Solid)
- Optimizing images and performance
- Configuring
astro.config.mjs - Building live data collections with Live Loaders
Core Workflow
1. Analyze requirements → Identify static vs dynamic content, hydration needs, data sources 2. Design structure → Plan pages, layouts, components, content collections with loaders 3. Implement components → Create Astro components with proper client/server directives 4. Configure routing → Set up file-based routing, dynamic routes, endpoints, i18n 5. Optimize delivery → Configure adapters, image optimization, view transitions, caching
Expert Decision Frameworks
Output Mode Selection
static (default)
├── Blog, docs, landing pages, portfolios
├── Content changes per-deploy, not per-request
├── <500 pages and builds under 5 min
└── No user-specific content needed
hybrid (80% of real-world projects)
├── Mostly static + login/dashboard/API routes
├── E-commerce: static catalog + dynamic cart/checkout
├── Use server islands to avoid making whole pages SSR
└── Best balance of performance + flexibility
server (rarely needed)
├── >80% of pages need request data (cookies, headers, DB)
├── Full SaaS/dashboard behind auth
└── Warning: you lose edge HTML caching on all pagesSigns you picked wrong:
- Builds >10 min with
getStaticPaths→ switch tohybrid - Using
prerender = falseon >50% of pages → switch toserver - Whole app is
serverbut only 2 pages read cookies → switch tohybrid
Hydration Strategy — Common Mistakes
- `client:visible` on hero/header → It's already in viewport at load time, so it hydrates immediately anyway. Use
client:loaddirectly and skip the IntersectionObserver overhead. - `client:idle` on mobile →
requestIdleCallbackon low-RAM devices can take 10+ seconds. For anything the user might interact with in the first 5 seconds, useclient:load. - Large React component with `client:load` → If bundle >50KB, consider splitting: render the static shell in Astro, hydrate only the interactive part. Or use
client:idleif it's below the fold. - Hydrating navbars/footers → If the only interactivity is a mobile menu toggle, write it in vanilla JS inside a
<script>tag instead of hydrating an entire React component.
Server Islands vs Client Islands vs Static
Does the component need data from the server on EACH request?
(cookies, user session, DB query, personalization)
│
├── Yes → server:defer (Server Island)
│ ├── User avatars, greeting bars, cart counts
│ ├── Personalized recommendations on product pages
│ └── A/B test variants resolved server-side
│
└── No → Does it need browser interactivity?
│
├── Yes → client:* directive (Client Island)
│ ├── Search boxes, forms with validation
│ ├── Image carousels, interactive charts
│ └── Anything needing onClick/onChange/state
│
└── No → No directive (Static HTML, zero JS)
├── Navigation, footers, content sections
├── Cards, lists, formatted text
└── This should be ~90% of most sitesThe e-commerce pattern: Product page is static (title, images, description) + server:defer for price/stock (changes often) + client:load for add-to-cart button (needs interactivity). Three rendering strategies on one page.
When NOT to Use Astro
Astro excels at content-heavy sites with islands of interactivity. Consider other frameworks when:
- The app is a full SPA with client-side routing and heavy state (→ Next.js, SvelteKit, Remix)
- Real-time collaborative features are core (→ Next.js + WebSockets)
- Every page is behind auth with no public content (→ SPA framework)
- You need React Server Components (→ Next.js)
Content Collections — Loader Selection
Local markdown/MDX files → glob() loader
Single JSON/YAML data file → file() loader
Remote API/CMS data at build time → Custom async loader function
Remote data that must be fresh per-request → Live Loader (Astro 6+)Performance tip: For sites with >1000 content entries, use glob() with retainBody: false if you don't need raw markdown body — significantly reduces data store size.
Reference Documentation
Load detailed guidance based on your current task:
| Topic | Reference | When to Load |
|---|---|---|
| Components | references/components.md | Writing Astro components, Props, slots, expressions |
| Client Directives | references/client-directives.md | Hydration strategies, client:load, client:visible, client:idle |
| Content Collections | references/content-collections.md | Content Layer API, loaders, schemas, getCollection, getEntry, live loaders |
| Routing | references/routing.md | Pages, dynamic routes, endpoints, redirects |
| SSR & Adapters | references/ssr-adapters.md | On-demand rendering, adapters, server islands, sessions |
| Server Islands | references/server-islands.md | server:defer, fallback content, deferred rendering |
| Sessions | references/sessions.md | Astro.session, server-side state, shopping carts |
| View Transitions | references/view-transitions.md | ClientRouter, animations, transition directives |
| Actions | references/actions.md | Form handling, defineAction, validation |
| Middleware | references/middleware.md | onRequest, sequence, context.locals |
| Styling | references/styling.md | Scoped CSS, global styles, class:list |
| Images | references/images.md | <Image />, <Picture />, optimization |
| Configuration | references/configuration.md | astro.config.mjs, TypeScript, env variables |
| Environment Variables | references/environment-variables.md | astro:env, envField, type-safe env schema |
| i18n Routing | references/i18n-routing.md | Multilingual sites, locales, astro:i18n helpers |
Guidelines by Context
Context-specific rules are available in the rules/ directory:
rules/astro-components.rule.md→ Component structure patternsrules/client-hydration.rule.md→ Hydration strategy decisionsrules/content-collections.rule.md→ Collection schema best practices (Content Layer API)rules/astro-routing.rule.md→ Routing patterns and dynamic routesrules/astro-ssr.rule.md→ SSR configuration and adaptersrules/astro-images.rule.md→ Image optimization patternsrules/astro-typescript.rule.md→ TypeScript configurationrules/server-islands.rule.md→ Server island patterns andserver:deferrules/sessions.rule.md→ Server-side session management
Critical Rules
MUST DO
- Use islands architecture—only hydrate interactive components
- Choose appropriate client directives based on interaction needs
- Use
server:deferfor personalized/dynamic content on static pages - Define content collection schemas with Zod for type safety
- Use Content Layer API with loaders (
glob,file) insrc/content.config.ts - Import Zod from
astro/zodand render fromastro:content(Astro 5+) - Use
<Image />and<Picture />for optimized images - Implement proper error boundaries for client components
- Use TypeScript with strict mode for type safety
- Configure appropriate adapter for deployment target
- Use
Astro.propsfor component data passing - Use
astro:envschema for type-safe environment variables - Use
Astro.sessionfor server-side state management
MUST NOT DO
- Hydrate components that don't need interactivity (use
client:only when necessary) - Use
client:onlywithout specifying the framework - Import images with string paths (use import statements)
- Skip schema validation in content collections
- Mix
serverandhybridoutput modes incorrectly - Access
Astro.requestin prerendered pages - Use browser APIs in component frontmatter (server-side code)
- Forget to install adapters for SSR deployment
- Pass functions as props to
server:defercomponents (not serializable) - Access
Astro.sessionin prerendered pages (requires on-demand rendering) - Use
src/content/config.tsfor new projects (usesrc/content.config.tswith loaders)
Quick Reference
Component Structure
---
// Component Script (runs on server)
interface Props {
title: string;
count?: number;
}
const { title, count = 0 } = Astro.props;
const data = await fetch('https://api.example.com/data');
---
<!-- Component Template -->
<div>
<h1>{title}</h1>
<p>Count: {count}</p>
</div>
<style>
/* Scoped by default */
h1 { color: navy; }
</style>Directive Priority
1. No directive → Static HTML, zero JavaScript 2. `server:defer` → Deferred server rendering (server island) 3. `client:load` → Hydrate immediately on page load 4. `client:idle` → Hydrate when browser is idle 5. `client:visible` → Hydrate when component enters viewport 6. `client:media` → Hydrate when media query matches 7. `client:only` → Skip SSR, render only on client
Content Collection Schema (Astro 5+)
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { blog };Server Island
---
import UserAvatar from '../components/UserAvatar.astro';
---
<UserAvatar server:defer>
<img slot="fallback" src="/generic-avatar.svg" alt="Loading..." />
</UserAvatar>Output Format
When implementing Astro features, provide:
1. Component file (.astro with frontmatter and template) 2. Configuration updates (astro.config.mjs if needed) 3. Content collection schema (if using collections) 4. TypeScript types (for Props and data) 5. Brief explanation of hydration strategy chosen
Technologies
Astro 5+/6+, Islands Architecture, Content Layer API (glob/file loaders, live loaders), Zod Schemas, View Transitions API, Server Islands (server:defer), Sessions, Actions, Middleware, astro:env (type-safe environment variables), i18n Routing, Adapters (Node, Vercel, Netlify, Cloudflare, Deno), React/Vue/Svelte/Solid integrations, Image Optimization, MDX, Markdoc, TypeScript, Scoped CSS, Tailwind CSS
Astro Framework Cheatsheet
Version: 2.0.0 | Astro 5.x | Updated: 2026-03-22 | Author: webreactiva.com
---
Quick Reference Card
| Topic | Rule of Thumb |
|---|---|
| Components | Define interface Props; frontmatter = server only; use class:list for conditional classes |
| Hydration | Default: no directive (zero JS). Question every client: |
| Server Islands | server:defer for personalized/dynamic server content on cached pages |
| Content Collections | Content Layer API with loaders in src/content.config.ts (NOT src/content/config.ts) |
| Rendering content | import { render } from 'astro:content' then const { Content } = await render(entry) |
| Routing | File-based; getStaticPaths() required for dynamic routes in static mode |
| Output modes | static (default), server (all SSR), hybrid (static default, opt-in SSR) |
| Sessions | Astro.session?.get/set; always use optional chaining; SSR only |
| Env vars | astro:env schema with envField for type safety; import from astro:env/client or astro:env/server |
| Images | Import local images; use <Image> / <Picture> from astro:assets; always set alt text |
| i18n | Use getRelativeLocaleUrl() for links, never hardcode locale prefixes |
| TypeScript | Extend astro/tsconfigs/strict; define path aliases; type App.Locals in src/env.d.ts |
| API routes | Export named handlers (GET, POST, etc.) typed as APIRoute |
---
Decision Trees
Output Mode
All static? ──> output: 'static' (default, no adapter)
All dynamic? ──> output: 'server' + adapter
Mix? ──> output: 'hybrid' + adapter
Opt into SSR (hybrid): export const prerender = false
Opt out of SSR (server): export const prerender = trueHydration Directive
Needs interactivity?
├─ No ──> No directive (zero JS)
└─ Yes ──> Above fold + critical? ──> client:load
Above fold + non-critical? ──> client:idle
Below fold? ──> client:visible
Device-specific? ──> client:media="(query)"
Browser APIs only, skip SSR? ──> client:only="react"Server Island vs Client Island
Needs server data (cookies, DB, personalization)? ──> server:defer + slot="fallback"
Needs browser interactivity? ──> client:* directive
Neither? ──> Static .astro component---
Critical "NEVER Do" List
- NEVER access
window/documentin.astrofrontmatter (server-only) - NEVER use
client:loadon everything — defeats islands architecture - NEVER use
client:onlywithout the framework string argument - NEVER use plain
z.date()for frontmatter dates — usez.coerce.date() - NEVER use string paths for local images (
src="/images/hero.jpg") — import them - NEVER access
Astro.request/Astro.cookies/Astro.sessionin prerendered pages - NEVER rely on
Astro.urlinside a server island (returns internal route; useRefererheader) - NEVER pass large objects/functions as server island props (IDs only; >2048 bytes forces POST)
- NEVER use sessions in edge middleware or static pages
- NEVER forget
getStaticPaths()for[param]routes in static output - NEVER skip
slot="fallback"onserver:defercomponents - NEVER use
any— useunknownand narrow - NEVER import Zod from
zod— useastro/zod(Astro 5+)
---
Astro 5+ Migration Notes (Post-Training Knowledge)
Content Collections (Content Layer API)
- Config file:
src/content.config.ts(wassrc/content/config.ts) - Uses
loaderproperty instead oftype: 'content'/type: 'data' - Loaders:
import { glob, file } from 'astro/loaders' - Zod:
import { z } from 'astro/zod'(NOT fromzodpackage) - Rendering:
import { render } from 'astro:content'thenawait render(entry)(wasentry.render()) reference()for cross-collection relationships;image()schema helper for optimized images
server:defer (Stable in Astro 5)
- Requires adapter; props must be serializable and small
- Use
Refererheader inside island to get parent page URL
Sessions (Astro 5.7+)
Astro.session?.get(key)/.set(key, value)— always optional-chain- Configure
session.driverin config viasessionDriversfromastro/config session.regenerate()after login,session.destroy()on logout- Type via
App.SessionDatainsrc/env.d.ts
astro:env (Stable in Astro 5)
- Schema in
astro.config.mjswithenvFieldfromastro/config - Import from
astro:env/clientorastro:env/server - Three kinds: public client (bundled), public server (server bundle), secret server (not bundled)
- No secret client vars — not supported by design
Actions
defineActionfromastro:actionswithastro/zodschemas
Key Patterns (Brief)
- Component structure: imports,
interface Props, destructure with defaults, logic, template, scoped<style> - Slots:
Astro.slots.has('name')to conditionally render named slot wrappers - Middleware:
defineMiddlewarefromastro:middleware; chain withsequence(a, b); file:src/middleware.ts - Pagination:
getStaticPaths({ paginate })returnspage.data,page.url.prev/next - Redirects:
redirects: { '/old': '/new' }inastro.config.mjs - Cookies (SSR):
Astro.cookies.get('name')?.value/.set('name', value, options)
References
Deep-dive docs with full code examples live in references/:
actions.md client-directives.md components.md configuration.md content-collections.md environment-variables.md i18n-routing.md images.md middleware.md routing.md server-islands.md sessions.md ssr-adapters.md styling.md view-transitions.md
Actions
Actions provide type-safe form handling and server functions in Astro.
Setup
Actions are defined in src/actions/index.ts:
// src/actions/index.ts
import { defineAction, z } from 'astro:actions';
export const server = {
// Actions go here
};Defining Actions
Basic Action
// src/actions/index.ts
import { defineAction, z } from 'astro:actions';
export const server = {
subscribe: defineAction({
input: z.object({
email: z.string().email(),
name: z.string().min(2),
}),
handler: async ({ email, name }) => {
// Save to database, send email, etc.
await db.subscribers.create({ email, name });
return { success: true, message: 'Subscribed!' };
},
}),
};Action with Accept Header
export const server = {
// Accepts form data
submitForm: defineAction({
accept: 'form', // Parses FormData
input: z.object({
email: z.string().email(),
message: z.string(),
}),
handler: async ({ email, message }) => {
await sendEmail(email, message);
return { sent: true };
},
}),
// Accepts JSON (default)
createPost: defineAction({
accept: 'json', // Default
input: z.object({
title: z.string(),
content: z.string(),
}),
handler: async ({ title, content }) => {
const post = await db.posts.create({ title, content });
return post;
},
}),
};Action Without Input
export const server = {
getCurrentUser: defineAction({
handler: async (_, context) => {
const user = context.locals.user;
return user || null;
},
}),
};Using Actions
In Forms (Progressive Enhancement)
---
import { actions } from 'astro:actions';
---
<form method="POST" action={actions.subscribe}>
<input type="email" name="email" required />
<input type="text" name="name" required />
<button type="submit">Subscribe</button>
</form>With JavaScript
---
import { actions } from 'astro:actions';
---
<form id="subscribe-form">
<input type="email" name="email" required />
<input type="text" name="name" required />
<button type="submit">Subscribe</button>
</form>
<script>
import { actions } from 'astro:actions';
const form = document.getElementById('subscribe-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const { data, error } = await actions.subscribe({
email: formData.get('email'),
name: formData.get('name'),
});
if (error) {
console.error(error.message);
return;
}
console.log('Success:', data.message);
});
</script>Server-Side Calls
---
import { actions } from 'astro:actions';
// Call action from server
const { data, error } = await actions.subscribe({
email: 'user@example.com',
name: 'John Doe',
});
---Handling Results
getActionResult
Get the result of a form submission:
---
import { actions, getActionResult } from 'astro:actions';
const result = await getActionResult(actions.subscribe);
if (result?.error) {
// Handle validation errors
}
---
{result?.data && (
<p class="success">{result.data.message}</p>
)}
{result?.error && (
<p class="error">{result.error.message}</p>
)}
<form method="POST" action={actions.subscribe}>
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>Error Handling
import { defineAction, z, ActionError } from 'astro:actions';
export const server = {
createUser: defineAction({
input: z.object({
email: z.string().email(),
}),
handler: async ({ email }) => {
const existing = await db.users.findByEmail(email);
if (existing) {
throw new ActionError({
code: 'CONFLICT',
message: 'User already exists',
});
}
return await db.users.create({ email });
},
}),
};Error Codes
Available error codes:
BAD_REQUEST- Invalid inputUNAUTHORIZED- Authentication requiredFORBIDDEN- Permission deniedNOT_FOUND- Resource not foundCONFLICT- Resource conflictPRECONDITION_FAILED- Condition not metINTERNAL_SERVER_ERROR- Server error
---
const result = await getActionResult(actions.createUser);
if (result?.error?.code === 'CONFLICT') {
// Handle duplicate user
}
---Input Validation
Zod Schema Validation
import { defineAction, z } from 'astro:actions';
export const server = {
updateProfile: defineAction({
input: z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores'),
bio: z.string().max(500).optional(),
birthdate: z.coerce.date()
.min(new Date('1900-01-01'))
.max(new Date()),
tags: z.array(z.string()).max(5).default([]),
}),
handler: async (data) => {
return await db.profiles.update(data);
},
}),
};Accessing Validation Errors
---
const result = await getActionResult(actions.updateProfile);
const fieldErrors = result?.error?.fields;
// { username: ['Too short'], bio: ['Too long'] }
---
<form method="POST" action={actions.updateProfile}>
<input type="text" name="username" />
{fieldErrors?.username && (
<span class="error">{fieldErrors.username[0]}</span>
)}
</form>Context Access
import { defineAction, z } from 'astro:actions';
export const server = {
protectedAction: defineAction({
input: z.object({ data: z.string() }),
handler: async ({ data }, context) => {
// Access request
const ip = context.request.headers.get('x-forwarded-for');
// Access cookies
const token = context.cookies.get('session')?.value;
// Access locals (from middleware)
const user = context.locals.user;
if (!user) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'Must be logged in',
});
}
return { processed: true };
},
}),
};File Uploads
export const server = {
uploadImage: defineAction({
accept: 'form',
input: z.object({
image: z.instanceof(File),
description: z.string().optional(),
}),
handler: async ({ image, description }) => {
const buffer = await image.arrayBuffer();
const path = await saveFile(buffer, image.name);
return { url: path };
},
}),
};<form method="POST" action={actions.uploadImage} enctype="multipart/form-data">
<input type="file" name="image" accept="image/*" />
<input type="text" name="description" />
<button type="submit">Upload</button>
</form>Redirect After Action
import { defineAction, z, ActionError } from 'astro:actions';
export const server = {
login: defineAction({
accept: 'form',
input: z.object({
email: z.string().email(),
password: z.string(),
}),
handler: async ({ email, password }, context) => {
const user = await authenticate(email, password);
if (!user) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'Invalid credentials',
});
}
context.cookies.set('session', user.token, { httpOnly: true });
// Return redirect URL for client to handle
return { redirect: '/dashboard' };
},
}),
};---
const result = await getActionResult(actions.login);
if (result?.data?.redirect) {
return Astro.redirect(result.data.redirect);
}
---Best Practices
1. Use Zod for validation - Type-safe input handling 2. Throw ActionError for business logic errors - Proper error codes 3. Access context for auth - Use context.locals from middleware 4. Use `accept: 'form'` - For form submissions without JS 5. Handle errors gracefully - Show user-friendly messages 6. Use getActionResult - For progressive enhancement 7. Keep handlers focused - Single responsibility
Client Directives
Client directives control how UI framework components (React, Vue, Svelte, etc.) are hydrated on the client.
Overview
By default, UI framework components are not hydrated - they render to static HTML. Client directives tell Astro when and how to hydrate them.
<!-- Static - no JavaScript shipped -->
<ReactComponent />
<!-- Hydrated - JavaScript loaded -->
<ReactComponent client:load />Available Directives
client:load
Hydrate immediately when the page loads. Use for above-the-fold interactive content.
<InteractiveCounter client:load />
<NavigationMenu client:load />Use when:
- Component is immediately visible
- User interaction expected right away
- Critical interactive functionality
client:idle
Hydrate when the browser is idle (using requestIdleCallback). Good for lower-priority interactivity.
<SidebarWidget client:idle />
<FeedbackForm client:idle />Use when:
- Component is visible but not immediately needed
- User likely to interact after initial page load
- Want to prioritize above-the-fold content
client:visible
Hydrate when the component enters the viewport. Uses Intersection Observer.
<CommentsSection client:visible />
<ImageCarousel client:visible />
<FooterNewsletter client:visible />Use when:
- Component is below the fold
- Heavy components that shouldn't block initial load
- Lazy-loaded features
With options:
<!-- Hydrate when 50% visible with 200px margin -->
<HeavyComponent client:visible={{
rootMargin: "200px",
threshold: 0.5
}} />client:media
Hydrate only when a media query matches. Perfect for responsive components.
<!-- Only hydrate on desktop -->
<DesktopOnlyChart client:media="(min-width: 768px)" />
<!-- Only hydrate on mobile -->
<MobileMenu client:media="(max-width: 767px)" />
<!-- Only hydrate if reduced motion not preferred -->
<AnimatedHero client:media="(prefers-reduced-motion: no-preference)" />Use when:
- Component only needed at certain viewport sizes
- Device-specific functionality
- Accessibility considerations
client:only
Skip server rendering entirely. Component only renders on the client.
<!-- Must specify the framework -->
<ReactOnlyComponent client:only="react" />
<VueOnlyComponent client:only="vue" />
<SvelteOnlyComponent client:only="svelte" />
<SolidOnlyComponent client:only="solid-js" />
<PreactOnlyComponent client:only="preact" />Use when:
- Component uses browser-only APIs (window, document, localStorage)
- Component has SSR incompatibilities
- Third-party component doesn't support SSR
Important: Always specify the framework name!
Mixing Multiple Frameworks
Astro supports multiple UI frameworks in the same project:
---
import ReactCounter from './ReactCounter.jsx';
import VueCard from './VueCard.vue';
import SvelteButton from './SvelteButton.svelte';
---
<ReactCounter client:load />
<VueCard client:visible />
<SvelteButton client:idle />Directive Priority Guide
Choose the right directive based on priority:
| Priority | Directive | JavaScript Load | Use Case |
|---|---|---|---|
| 1 (Highest) | client:load | Immediate | Critical interactivity |
| 2 | client:idle | When idle | Important but not urgent |
| 3 | client:visible | When visible | Below-the-fold content |
| 4 | client:media | When matches | Responsive components |
| Special | client:only | Client only | Browser-only APIs |
Common Patterns
Navigation with Mobile Menu
---
import DesktopNav from './DesktopNav.astro'; // Static
import MobileMenu from './MobileMenu.jsx';
---
<DesktopNav />
<MobileMenu client:media="(max-width: 768px)" />Progressive Enhancement
---
import StaticContent from './StaticContent.astro';
import EnhancedFeatures from './EnhancedFeatures.jsx';
---
<!-- Always visible -->
<StaticContent />
<!-- Enhanced when visible -->
<EnhancedFeatures client:visible />Heavy Component Optimization
---
import DataVisualization from './DataVisualization.jsx';
---
<!-- Only load when visible and browser is ready -->
<div class="chart-container">
<DataVisualization
client:visible={{ rootMargin: "100px" }}
data={chartData}
/>
</div>Expert Gotchas
client:visible on Above-the-Fold Components
client:visible uses IntersectionObserver to detect viewport entry. If the component is already visible on load (hero, header, above-fold CTA), the observer fires immediately anyway — you pay the observer setup cost for zero benefit. Use client:load directly.
client:idle on Low-End Mobile
requestIdleCallback on low-RAM Android devices can delay 10+ seconds because the main thread never goes truly idle. If users might interact with the component within the first 5 seconds, use client:load instead. Reserve client:idle for genuinely non-urgent components (analytics widgets, secondary sidebars).
The Navbar Hydration Trap
A common mistake: wrapping an entire React/Vue navbar in client:load just for a mobile hamburger toggle. The toggle is 5 lines of vanilla JS, but you're shipping an entire framework runtime. Instead:
<nav>
<ul class="nav-links">{/* static links */}</ul>
<button id="menu-toggle" aria-expanded="false">Menu</button>
</nav>
<script>
const toggle = document.getElementById('menu-toggle');
toggle?.addEventListener('click', () => {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!expanded));
document.querySelector('.nav-links')?.classList.toggle('open');
});
</script>Rule of thumb: If the only interactivity is show/hide or toggle, use a <script> tag. If there's state management, forms, or complex UI logic, use a framework component with a client directive.
Large Bundle Splitting
If a React component's bundle exceeds ~50KB, consider splitting:
1. Render the static shell as an Astro component (heading, layout, placeholder) 2. Hydrate only the interactive part as a smaller React island
<!-- Instead of one large hydrated component -->
<ProductPage client:load />
<!-- Split into static + interactive -->
<div class="product-layout">
<h1>{product.title}</h1>
<img src={product.image} alt={product.title} />
<p>{product.description}</p>
<!-- Only the interactive part hydrates -->
<AddToCartButton client:load productId={product.id} />
</div>When client:only Is Actually Needed
client:only skips SSR entirely — no HTML on first paint. This hurts SEO and perceived performance. Use it only when:
- The component crashes during SSR (e.g., reads
window.innerWidthat module scope) - A third-party library has no SSR support and no workaround
- The component is purely decorative and non-essential (e.g., a confetti animation)
If the component just needs window in an event handler, client:load works fine — window is available after hydration.
Best Practices
1. Default to no directive - Only hydrate what needs interactivity 2. Use `client:visible` for below-fold content - Reduce initial bundle 3. Avoid `client:load` for everything - Defeats the purpose of islands 4. Use `client:media` for responsive components - Don't load unused code 5. Always specify framework for `client:only` - Required parameter 6. Test without JavaScript - Ensure graceful degradation 7. Monitor bundle sizes - Each hydrated component adds JavaScript
Debugging
Check which components are hydrated:
// In browser console
document.querySelectorAll('[data-astro-cid]').forEach(el => {
console.log(el, el.dataset);
});Performance Impact
| Directive | Initial Load | TTI Impact | Bundle Size |
|---|---|---|---|
| None | Fastest | None | 0 KB |
client:visible | Fast | Low | Deferred |
client:idle | Fast | Low | Deferred |
client:media | Fast | Conditional | Conditional |
client:load | Slower | High | Immediate |
client:only | Slowest | High | Immediate + no SSR |
Astro Components
Component Structure
Astro components use a .astro extension and consist of two main parts:
---
// Component Script (Frontmatter)
// Runs on the server at build time (or request time for SSR)
import SomeComponent from './SomeComponent.astro';
import { getCollection } from 'astro:content';
interface Props {
title: string;
description?: string;
}
const { title, description = 'Default description' } = Astro.props;
// Top-level await is supported
const posts = await getCollection('blog');
const response = await fetch('https://api.example.com/data');
const data = await response.json();
---
<!-- Component Template -->
<html>
<head>
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
<p>{description}</p>
<SomeComponent />
</body>
</html>Props
Defining Props with TypeScript
---
interface Props {
name: string;
greeting?: string;
items: string[];
}
const { name, greeting = "Hello", items } = Astro.props;
---
<h1>{greeting}, {name}!</h1>
<ul>
{items.map((item) => <li>{item}</li>)}
</ul>Accessing All Props
---
const allProps = Astro.props;
// Spread to child component
---
<ChildComponent {...allProps} />Slots
Default Slot
<!-- Wrapper.astro -->
---
---
<div class="wrapper">
<slot /> <!-- Children go here -->
</div><!-- Usage -->
<Wrapper>
<p>This content goes into the slot</p>
</Wrapper>Named Slots
<!-- Layout.astro -->
---
---
<div class="container">
<header>
<slot name="header" />
</header>
<main>
<slot /> <!-- Default slot -->
</main>
<footer>
<slot name="footer" />
</footer>
</div><!-- Usage -->
<Layout>
<h1 slot="header">Page Title</h1>
<p>Main content goes in default slot</p>
<p slot="footer">Footer content</p>
</Layout>Fallback Content
<slot>
<p>This shows if no content is provided</p>
</slot>Checking for Slot Content
---
const hasHeader = Astro.slots.has('header');
---
{hasHeader && (
<header>
<slot name="header" />
</header>
)}Rendering Slots Programmatically
---
const html = await Astro.slots.render('default');
---
<Fragment set:html={html} />Expressions and Dynamic Content
Basic Expressions
---
const name = "Astro";
const items = ['Apple', 'Banana', 'Cherry'];
const visible = true;
---
<h1>{name}</h1>
<p>{5 + 5}</p>
<p>{visible ? 'Shown' : 'Hidden'}</p>
<!-- Lists -->
<ul>
{items.map((item) => <li>{item}</li>)}
</ul>
<!-- Conditional rendering -->
{visible && <p>This is visible</p>}
<!-- Dynamic HTML -->
<div set:html={rawHtmlString} />Dynamic Attributes
---
const dynamicId = "my-id";
const dynamicClass = "active";
---
<div id={dynamicId} class={dynamicClass}>Content</div>
<!-- Boolean attributes -->
<input type="checkbox" checked={true} />
<button disabled={false}>Click</button>
<!-- Spreading attributes -->
<div {...Astro.props}>Content</div>class:list Directive
---
const isActive = true;
const isDisabled = false;
---
<div class:list={[
'base-class',
{ 'active': isActive },
{ 'disabled': isDisabled },
isActive && 'conditional-class',
]}>
Content
</div>
<!-- Output: <div class="base-class active conditional-class">Content</div> -->HTML Attributes
set:html
Inject raw HTML (be careful with user input - XSS risk):
---
const rawHTML = "<strong>Bold</strong>";
---
<div set:html={rawHTML} />set:text
Safely set text content:
---
const text = "Some text content";
---
<p set:text={text} />Fragment
Wrap multiple elements without adding extra DOM:
---
import { Fragment } from 'astro:components';
---
<Fragment>
<li>Item 1</li>
<li>Item 2</li>
</Fragment>
<!-- Or using shorthand -->
<>
<li>Item 1</li>
<li>Item 2</li>
</>Component Scripts
Imports
---
// Astro components
import Header from '../components/Header.astro';
// UI framework components
import ReactComponent from '../components/ReactComponent.jsx';
// Data
import { getCollection } from 'astro:content';
// Utilities
import { formatDate } from '../utils/date';
// Styles
import '../styles/global.css';
// JSON data
import data from '../data/config.json';
---Using Astro Global
---
// URL information
const currentPath = Astro.url.pathname;
const searchParams = Astro.url.searchParams;
// Request (SSR only)
const userAgent = Astro.request.headers.get('user-agent');
// Cookies (SSR only)
const token = Astro.cookies.get('token');
// Redirect (SSR only)
if (!token) {
return Astro.redirect('/login');
}
// Props
const { title } = Astro.props;
// Slots
const hasContent = Astro.slots.has('default');
// Site configuration
const siteUrl = Astro.site;
// Generator
const generator = Astro.generator; // "Astro v4.x.x"
// Current locale (i18n)
const locale = Astro.currentLocale;
---Best Practices
1. Keep frontmatter focused - Only include necessary logic 2. Use TypeScript interfaces - Define Props for type safety 3. Prefer slots over props for complex content - Better composability 4. Avoid side effects in frontmatter - It runs on every render 5. Use `class:list` for conditional classes - Cleaner than ternaries 6. Extract reusable logic to utilities - Keep components clean
Configuration
Astro configuration lives in astro.config.mjs at the project root.
Basic Configuration
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
// Your configuration options here
});Common Options
Site URL
export default defineConfig({
site: 'https://example.com',
base: '/blog', // For subdirectory deployments
});Output Mode
import node from '@astrojs/node';
export default defineConfig({
output: 'static', // Default - all pages prerendered
// output: 'server', // All pages server-rendered
// output: 'hybrid', // Static by default, opt-in to SSR
adapter: node({ // Required for server/hybrid
mode: 'standalone',
}),
});Integrations
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
integrations: [
react(),
tailwind(),
mdx(),
sitemap(),
],
});Build Options
export default defineConfig({
build: {
format: 'directory', // /about/index.html (default)
// format: 'file', // /about.html
inlineStylesheets: 'auto', // Inline small stylesheets
// inlineStylesheets: 'always',
// inlineStylesheets: 'never',
assets: '_astro', // Assets directory name
},
compressHTML: true, // Minify HTML output
outDir: './dist', // Build output directory
publicDir: './public', // Static assets directory
});Dev Server
export default defineConfig({
server: {
port: 4321, // Default port
host: true, // Expose to network
open: true, // Open browser on start
},
devToolbar: {
enabled: true, // Show dev toolbar
},
});Prefetching
export default defineConfig({
prefetch: {
prefetchAll: true, // Prefetch all links
defaultStrategy: 'viewport', // 'hover' | 'viewport' | 'load'
},
});Vite Configuration
export default defineConfig({
vite: {
plugins: [],
resolve: {
alias: {
'@': '/src',
'@components': '/src/components',
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`,
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
},
},
},
},
},
});Markdown Configuration
import remarkToc from 'remark-toc';
import rehypeSlug from 'rehype-slug';
export default defineConfig({
markdown: {
syntaxHighlight: 'shiki', // 'shiki' | 'prism' | false
shikiConfig: {
theme: 'dracula',
wrap: true,
},
remarkPlugins: [
remarkToc,
[remarkPlugin, { option: true }], // With options
],
rehypePlugins: [
rehypeSlug,
],
gfm: true, // GitHub Flavored Markdown
smartypants: true, // Smart quotes
},
});i18n Configuration
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr', 'de'],
routing: {
prefixDefaultLocale: false, // /about vs /en/about
redirectToDefaultLocale: true,
},
fallback: {
es: 'en', // Fallback to English for Spanish
},
},
});Image Configuration
export default defineConfig({
image: {
// Allowed remote image domains
domains: ['example.com', 'cdn.example.com'],
// Allowed remote patterns
remotePatterns: [
{
protocol: 'https',
hostname: '**.amazonaws.com',
},
],
// Image service configuration
service: {
entrypoint: 'astro/assets/services/sharp',
config: {
limitInputPixels: false,
},
},
},
});Redirects
export default defineConfig({
redirects: {
'/old-page': '/new-page',
'/old-blog/[...slug]': '/blog/[...slug]',
'/twitter': {
status: 302,
destination: 'https://twitter.com/astrodotbuild',
},
},
});TypeScript Configuration
tsconfig.json
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"]
}
}
}Available presets:
astro/tsconfigs/base- Minimalastro/tsconfigs/strict- Recommendedastro/tsconfigs/strictest- Maximum strictness
Type Declarations
// src/env.d.ts
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_API_URL: string;
readonly DATABASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare namespace App {
interface Locals {
user: {
id: string;
name: string;
} | null;
}
}Environment Variables
.env Files
# .env
PUBLIC_API_URL=https://api.example.com
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=abc123Access in Code
---
// Server-side (all variables)
const dbUrl = import.meta.env.DATABASE_URL;
// Client-side (only PUBLIC_ prefixed)
const apiUrl = import.meta.env.PUBLIC_API_URL;
// Built-in variables
const mode = import.meta.env.MODE; // 'development' | 'production'
const prod = import.meta.env.PROD; // boolean
const dev = import.meta.env.DEV; // boolean
const site = import.meta.env.SITE; // From astro.config site
const base = import.meta.env.BASE_URL; // From astro.config base
---Type-Safe Environment (Astro 5+)
// astro.config.mjs
import { defineConfig, envField } from 'astro/config';
export default defineConfig({
env: {
schema: {
PUBLIC_API_URL: envField.string({
context: 'client',
access: 'public',
default: 'https://api.example.com',
}),
DATABASE_URL: envField.string({
context: 'server',
access: 'secret',
}),
PORT: envField.number({
context: 'server',
access: 'public',
default: 4321,
}),
FEATURE_FLAG: envField.boolean({
context: 'client',
access: 'public',
default: false,
}),
},
},
});---
import { PUBLIC_API_URL, DATABASE_URL } from 'astro:env/server';
import { PUBLIC_API_URL } from 'astro:env/client';
---Session Configuration (Astro 5.7+)
import { defineConfig, sessionDrivers } from 'astro/config';
export default defineConfig({
session: {
driver: sessionDrivers.redis({
url: process.env.REDIS_URL,
}),
// Or use filesystem, memory, etc.
},
});Experimental Features
export default defineConfig({
experimental: {
contentIntellisense: true,
clientPrerender: true,
},
});Note: Server islands (server:defer) are stable since Astro 5 — no experimental flag needed.Full Example
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import vercel from '@astrojs/vercel';
export default defineConfig({
site: 'https://mysite.com',
output: 'hybrid',
adapter: vercel(),
integrations: [
react(),
tailwind({
applyBaseStyles: false,
}),
mdx(),
sitemap(),
],
prefetch: {
prefetchAll: true,
},
i18n: {
defaultLocale: 'en',
locales: ['en', 'es'],
},
image: {
domains: ['images.unsplash.com'],
},
markdown: {
syntaxHighlight: 'shiki',
shikiConfig: {
theme: 'github-dark',
},
},
vite: {
resolve: {
alias: {
'@': '/src',
},
},
},
});Best Practices
1. Start with strict TypeScript - Better type safety 2. Use path aliases - Cleaner imports 3. Configure image domains - Security whitelist 4. Set site URL - Required for sitemaps and canonical URLs 5. Use hybrid output - Best of both worlds 6. Enable prefetching - Faster navigation 7. Configure Markdown plugins - Enhanced content 8. Type your env variables - Catch errors early
Content Collections
Content collections provide type-safe content management with schema validation using Zod.
Setup (Astro 5+ Content Layer API)
Directory Structure
src/
├── content.config.ts # Collection schemas (Astro 5+)
├── content/
│ ├── blog/ # Blog collection
│ │ ├── post-1.md
│ │ ├── post-2.mdx
│ │ └── drafts/ # Subdirectories supported
│ │ └── draft-1.md
│ └── authors/ # Another collection
│ └── john.jsonNote: In Astro 5+, the config file issrc/content.config.ts(notsrc/content/config.ts). Collections useloaderinstead oftype.
Configuration File (Astro 5+ — Recommended)
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';
const blogCollection = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
author: z.string(),
}),
});
const authorsCollection = defineCollection({
loader: file('src/data/authors.json'),
schema: z.object({
name: z.string(),
email: z.string().email(),
bio: z.string(),
avatar: z.string().url(),
social: z.object({
twitter: z.string().optional(),
github: z.string().optional(),
}).optional(),
}),
});
export const collections = {
blog: blogCollection,
authors: authorsCollection,
};Legacy Configuration (Astro 4)
// src/content/config.ts (legacy path)
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
type: 'content', // Markdown/MDX files
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
}),
});
const authorsCollection = defineCollection({
type: 'data', // JSON/YAML files
schema: z.object({
name: z.string(),
email: z.string().email(),
}),
});
export const collections = { blog: blogCollection, authors: authorsCollection };Built-in Loaders (Astro 5+)
glob() — Multiple files
Loads entries from directories of Markdown, MDX, JSON, YAML, or TOML files:
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({
base: './src/content/blog',
pattern: '**/*.{md,mdx}',
}),
schema: z.object({ title: z.string() }),
});Options: pattern, base, generateId() (custom ID generation), retainBody (set false to exclude raw body).
file() — Single file
Loads entries from a single JSON, YAML, or TOML file:
import { file } from 'astro/loaders';
const authors = defineCollection({
loader: file('src/data/authors.json'),
schema: z.object({ name: z.string() }),
});Supports a custom parser for non-standard formats (e.g., CSV).
Custom Loaders
Load from any source (APIs, databases, CMSes):
const products = defineCollection({
loader: async () => {
const response = await fetch('https://api.example.com/products');
const data = await response.json();
return data.map((product: any) => ({
id: product.id,
...product,
}));
},
schema: z.object({ name: z.string(), price: z.number() }),
});Object Loaders (Advanced)
For full control with incremental updates, caching, and file watching:
import type { Loader } from 'astro/loaders';
function myLoader(options: { url: string }): Loader {
return {
name: 'my-loader',
load: async ({ store, meta, logger }) => {
const lastModified = meta.get('lastModified');
const data = await fetchData(options.url, lastModified);
store.clear();
for (const item of data) {
store.set({ id: item.id, data: item });
}
meta.set('lastModified', new Date().toISOString());
},
};
}Live Loaders (Astro 6+)
Live loaders fetch data fresh on every request — no data store to update. Use for real-time data:
import type { LiveLoader } from 'astro/loaders';
function productLoader(config: { apiKey: string }): LiveLoader<Product> {
return {
name: 'product-loader',
loadCollection: async ({ filter }) => {
const data = await fetchProducts(config.apiKey, filter);
return {
entries: data.map(p => ({ id: p.sku, data: p })),
};
},
loadEntry: async ({ filter }) => {
const product = await fetchProduct(config.apiKey, filter.id);
if (!product) return undefined;
return { id: product.sku, data: product };
},
};
}Query live collections with getLiveCollection() and getLiveEntry():
---
import { getLiveCollection, getLiveEntry } from 'astro:content';
const { entries, error } = await getLiveCollection('products');
const { entry, error: entryError } = await getLiveEntry('products', 'sku-123');
---Collection Types (Legacy — Astro 4)
Content Collections (type: 'content')
For Markdown and MDX files with frontmatter:
---
title: "My First Post"
pubDate: 2024-01-15
author: "john"
---
# Hello World
This is my first blog post.Data Collections (type: 'data')
For JSON or YAML data files:
// src/content/authors/john.json
{
"name": "John Doe",
"email": "john@example.com",
"bio": "A passionate writer",
"avatar": "https://example.com/avatar.jpg"
}Schema Validation with Zod
Common Field Types
import { z } from 'astro/zod'; // Astro 5+
// import { z } from 'astro:content'; // Legacy (Astro 4)
const schema = z.object({
// Strings
title: z.string(),
slug: z.string().regex(/^[a-z0-9-]+$/),
// Numbers
order: z.number().int().positive(),
rating: z.number().min(0).max(5),
// Booleans
featured: z.boolean().default(false),
// Dates
pubDate: z.coerce.date(), // Converts strings to Date
// Arrays
tags: z.array(z.string()),
categories: z.array(z.enum(['tech', 'life', 'travel'])),
// Objects
author: z.object({
name: z.string(),
email: z.string().email(),
}),
// Enums
status: z.enum(['draft', 'published', 'archived']),
// Optional fields
description: z.string().optional(),
image: z.string().url().optional(),
// Default values
views: z.number().default(0),
// Unions
media: z.union([
z.object({ type: z.literal('image'), src: z.string() }),
z.object({ type: z.literal('video'), url: z.string() }),
]),
});Image Schema
// Astro 5+ with loader
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.md' }),
schema: ({ image }) => z.object({
title: z.string(),
cover: image(), // Validates and optimizes images
coverAlt: z.string(),
}),
});Reference Other Collections
import { defineCollection, reference } from 'astro:content';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.md' }),
schema: z.object({
title: z.string(),
author: reference('authors'), // References authors collection
relatedPosts: z.array(reference('blog')).optional(),
}),
});Querying Collections
getCollection()
---
import { getCollection } from 'astro:content';
// Get all entries
const allPosts = await getCollection('blog');
// Filter entries
const publishedPosts = await getCollection('blog', ({ data }) => {
return data.draft !== true;
});
// Filter by date
const recentPosts = await getCollection('blog', ({ data }) => {
return data.pubDate > new Date('2024-01-01');
});
// Sort entries
const sortedPosts = (await getCollection('blog'))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<ul>
{sortedPosts.map((post) => (
<li>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
<time>{post.data.pubDate.toLocaleDateString()}</time>
</li>
))}
</ul>getEntry()
---
import { getEntry } from 'astro:content';
// Get single entry by collection and slug
const post = await getEntry('blog', 'my-first-post');
// Get single entry by reference
const author = await getEntry(post.data.author);
// Check if entry exists
if (!post) {
return Astro.redirect('/404');
}
---
<article>
<h1>{post.data.title}</h1>
<p>By {author.data.name}</p>
</article>getEntries()
---
import { getEntry, getEntries } from 'astro:content';
const post = await getEntry('blog', 'my-post');
// Get multiple referenced entries
const relatedPosts = await getEntries(post.data.relatedPosts);
---Rendering Content
Astro 5+ (import render from astro:content)
---
import { getEntry, render } from 'astro:content';
const post = await getEntry('blog', 'my-post');
const { Content, headings } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<!-- Table of contents from headings -->
<nav>
{headings.map((h) => (
<a href={`#${h.slug}`} style={`margin-left: ${h.depth * 10}px`}>
{h.text}
</a>
))}
</nav>
<!-- Rendered content -->
<Content />
</article>Legacy (Astro 4)
---
const post = await getEntry('blog', 'my-post');
const { Content, headings } = await post.render();
---Dynamic Routes with Collections
---
// src/pages/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>Note: In Astro 5+, usepost.idinstead ofpost.slugfor routing params.
Expert Guidance
Loader Selection Decision Tree
Local markdown/MDX files → glob() loader
Single JSON/YAML data file → file() loader
Remote API/CMS data at build → Custom async loader function
Remote data fresh per-request → Live Loader (Astro 6+)Performance: retainBody for Large Sites
For sites with >1000 content entries where you only need frontmatter data (e.g., listing pages, tag indexes), disable body storage:
const blog = defineCollection({
loader: glob({
base: './src/content/blog',
pattern: '**/*.md',
retainBody: false, // Significantly reduces data store size
}),
schema: z.object({ title: z.string(), pubDate: z.coerce.date() }),
});Only use retainBody: false for collections where you don't call render(). If you need to render content on detail pages, keep the default (true).
Migration Pitfalls (Astro 4 → 5)
| What changed | Old (Astro 4) | New (Astro 5+) |
|---|---|---|
| Config file | src/content/config.ts | src/content.config.ts |
| Collection type | type: 'content' / type: 'data' | loader: glob(...) / loader: file(...) |
| Zod import | import { z } from 'astro:content' | import { z } from 'astro/zod' |
| Rendering | const { Content } = await entry.render() | import { render } from 'astro:content'; await render(entry) |
| Route param | post.slug | post.id |
The most common migration bug: importing z from astro:content instead of astro/zod. This still compiles but can cause subtle type mismatches with the new Content Layer API.
Schema Design Anti-Patterns
- `z.date()` for frontmatter dates → Always use
z.coerce.date(). YAML/frontmatter dates arrive as strings;z.date()rejects them silently - Missing `.default()` on optional booleans →
draft: z.boolean().optional()meansundefined, notfalse. Usez.boolean().default(false)so filtering logic works correctly - String paths for images → Use the
image()schema helper to enable Astro's image optimization pipeline. String URLs bypass optimization entirely
Best Practices
1. Always define schemas - Type safety and validation 2. Use `z.coerce.date()` - Handles string dates from frontmatter 3. Set sensible defaults - .default() reduces required fields 4. Filter drafts in production - Don't expose unpublished content 5. Use references for relationships - Type-safe cross-collection links 6. Keep slugs URL-friendly - Validate with regex 7. Use image schema for images - Enables optimization
Type-Safe Environment Variables (astro:env)
Added in: Astro 5.0+
The astro:env API provides a type-safe schema for environment variables with validation, proper context separation, and secret management.
Setup
Define Schema
// astro.config.mjs
import { defineConfig, envField } from 'astro/config';
export default defineConfig({
env: {
schema: {
// Public client variable — available everywhere
API_URL: envField.string({
context: 'client',
access: 'public',
optional: true,
}),
// Public server variable — server bundle only
PORT: envField.number({
context: 'server',
access: 'public',
default: 4321,
}),
// Secret server variable — not in bundle, runtime only
API_SECRET: envField.string({
context: 'server',
access: 'secret',
}),
// Boolean variable
FEATURE_FLAG: envField.boolean({
context: 'client',
access: 'public',
default: false,
}),
// Enum variable
LOG_LEVEL: envField.enum({
context: 'server',
access: 'public',
values: ['debug', 'info', 'warn', 'error'],
default: 'info',
}),
},
},
});Use Variables
Import from the appropriate module:
---
import { API_URL } from 'astro:env/client';
import { API_SECRET, PORT } from 'astro:env/server';
const data = await fetch(`${API_URL}/users`, {
headers: {
'Authorization': `Bearer ${API_SECRET}`,
},
});
---
<script>
import { API_URL } from 'astro:env/client';
fetch(`${API_URL}/ping`);
</script>Variable Types
There are three kinds, determined by context + access:
| Kind | Context | Access | Available In | In Bundle? |
|---|---|---|---|---|
| Public client | client | public | Client + Server | Yes |
| Public server | server | public | Server only | Yes |
| Secret server | server | secret | Server only | No |
Secret client variables are not supported — there's no safe way to send secrets to the client.
Data Types
envField.string({ context: 'server', access: 'public' })
envField.number({ context: 'server', access: 'public' })
envField.boolean({ context: 'client', access: 'public' })
envField.enum({ context: 'server', access: 'public', values: ['a', 'b'] })Common options: default, optional, min, max, length, url, includes, startsWith, endsWith.
getSecret()
For programmatic access to secrets (e.g., keys that depend on dynamic data):
import { getSecret } from 'astro:env/server';
// Returns string | undefined
const apiKey = getSecret('DYNAMIC_API_KEY');Use getSecret() instead of process.env — its implementation is provided by your adapter, so you won't need to update calls if you switch adapters.
When to Use astro:env vs import.meta.env
- `astro:env`: Type-safe, validated at build time, proper client/server separation. Use for all new projects.
- `import.meta.env`: Vite's built-in support. Still works, but no schema validation.
PUBLIC_prefix exposes to client.
Both can coexist in the same project.
Internationalization (i18n) Routing
Astro's built-in i18n routing helps you build multilingual sites with URL-based locale management, fallback content, and helper functions.
Setup
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr', 'de'],
},
});Folder Structure
Create locale-specific folders inside src/pages/:
src/pages/
├── index.astro → / (English, default)
├── about.astro → /about
├── es/
│ ├── index.astro → /es
│ └── about.astro → /es/about
└── fr/
├── index.astro → /fr
└── about.astro → /fr/aboutRouting Options
prefixDefaultLocale
By default, the default locale has no prefix. Set to true to add it:
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
routing: {
prefixDefaultLocale: true, // /en/about instead of /about
},
}Fallback Languages
Serve content from another locale when a page doesn't exist:
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
fallback: {
fr: 'es', // French pages fall back to Spanish
},
routing: {
fallbackType: 'rewrite', // Show fallback content at the original URL
// fallbackType: 'redirect', // (default) Redirect to fallback locale URL
},
}Manual Routing
For full control, disable Astro's i18n middleware:
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
routing: 'manual',
}Then implement your own middleware using helpers from astro:i18n:
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { redirectToDefaultLocale } from 'astro:i18n';
export const onRequest = defineMiddleware(async (ctx, next) => {
if (ctx.url.startsWith('/about')) {
return next();
}
return redirectToDefaultLocale(302);
});Custom Locale Paths
Map multiple language codes to a single URL path:
i18n: {
locales: ['es', 'en', {
path: 'french',
codes: ['fr', 'fr-BR', 'fr-CA'],
}],
defaultLocale: 'en',
}This maps fr, fr-BR, and fr-CA to /french/ URLs.
Domain-Based Routing
For server-rendered sites, map locales to different domains:
i18n: {
locales: ['es', 'en', 'fr'],
defaultLocale: 'en',
domains: {
fr: 'https://fr.example.com',
es: 'https://example.es',
},
}Requires output: 'server' and a site configuration.
Helper Functions
Import from astro:i18n:
---
import {
getRelativeLocaleUrl,
getAbsoluteLocaleUrl,
getRelativeLocaleUrlList,
getAbsoluteLocaleUrlList,
getPathByLocale,
getLocaleByPath,
} from 'astro:i18n';
// Generate localized URLs
const aboutES = getRelativeLocaleUrl('es', 'about');
// → /es/about
// Get all locale variants of a page
const allAboutUrls = getRelativeLocaleUrlList('about');
// → ['/about', '/es/about', '/fr/about']
---
<!-- Language switcher -->
<nav>
<a href={getRelativeLocaleUrl('en', 'about')}>English</a>
<a href={getRelativeLocaleUrl('es', 'about')}>Español</a>
<a href={getRelativeLocaleUrl('fr', 'about')}>Français</a>
</nav>Content Collections with i18n
Organize translated content in subdirectories:
src/content/blog/
├── en/
│ └── post-1.md
└── es/
└── post-1.md---
// src/pages/[lang]/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const pages = await getCollection('blog');
return pages.map(page => {
const [lang, ...slug] = page.id.split('/');
return { params: { lang, slug: slug.join('/') || undefined }, props: page };
});
}
const page = Astro.props;
const { Content } = await render(page);
---
<Content />Best Practices
1. Use `getRelativeLocaleUrl()` for generating links — don't hardcode locale prefixes 2. Set up fallbacks to avoid 404s for untranslated content 3. Use `rewrite` fallback type for better UX (shows content at original URL) 4. Consider `prefixDefaultLocale: true` for consistency across all locales 5. Type locale params to catch typos at build time
Images
Astro provides built-in image optimization through the astro:assets module.
Image Component
Basic Usage
---
import { Image } from 'astro:assets';
import heroImage from '../images/hero.jpg';
---
<Image src={heroImage} alt="Hero image" />Output: Optimized image with proper format, dimensions, and lazy loading.
With Properties
---
import { Image } from 'astro:assets';
import photo from '../images/photo.jpg';
---
<Image
src={photo}
alt="A description"
width={800}
height={600}
format="webp"
quality={80}
loading="lazy"
decoding="async"
class="rounded-lg shadow-md"
/>Remote Images
---
import { Image } from 'astro:assets';
---
<Image
src="https://example.com/image.jpg"
alt="Remote image"
width={400}
height={300}
inferSize={false}
/>For remote images, width and height are required (or use inferSize).
Infer Size for Remote Images
<Image
src="https://example.com/image.jpg"
alt="Remote image"
inferSize
/>Picture Component
Provides responsive images with multiple formats:
---
import { Picture } from 'astro:assets';
import photo from '../images/photo.jpg';
---
<Picture
src={photo}
alt="Responsive image"
formats={['avif', 'webp', 'jpg']}
widths={[400, 800, 1200]}
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
/>Output:
<picture>
<source srcset="..." type="image/avif" sizes="...">
<source srcset="..." type="image/webp" sizes="...">
<img src="..." alt="Responsive image" loading="lazy" decoding="async">
</picture>getImage Function
For programmatic image processing:
---
import { getImage } from 'astro:assets';
import background from '../images/background.jpg';
const optimizedBg = await getImage({
src: background,
format: 'webp',
width: 1920,
quality: 80,
});
---
<div style={`background-image: url(${optimizedBg.src})`}>
Content with background
</div>Image Paths
Local Images (src/)
---
// Import from src directory
import hero from '../images/hero.jpg';
import logo from '@/assets/logo.png'; // Using alias
---
<Image src={hero} alt="Hero" />
<Image src={logo} alt="Logo" />Public Folder Images
Images in public/ are not optimized:
<!-- Not optimized, served as-is -->
<img src="/images/static-image.jpg" alt="Static" />
<!-- Can still use Image component with remote-like syntax -->
<Image
src="/images/static-image.jpg"
alt="Static"
width={400}
height={300}
/>Dynamic Imports
---
const images = import.meta.glob<{ default: ImageMetadata }>(
'../images/*.{jpg,png,gif}'
);
const imagePaths = Object.keys(images);
---
{imagePaths.map(async (path) => {
const image = await images[path]();
return <Image src={image.default} alt="" />;
})}Content Collections Images
Schema with Image
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
title: z.string(),
cover: image(),
coverAlt: z.string(),
}),
});
export const collections = { blog };Markdown Frontmatter
---
title: "My Post"
cover: "./images/cover.jpg"
coverAlt: "Post cover image"
---
Content here...Using in Template
---
import { Image } from 'astro:assets';
import { getEntry } from 'astro:content';
const post = await getEntry('blog', 'my-post');
---
<Image src={post.data.cover} alt={post.data.coverAlt} />Image Formats
Supported output formats:
webp(default for optimization)avif(best compression, slower)png(lossless)jpg/jpeg(lossy)svg(pass-through, no optimization)gif(pass-through)
<Image src={photo} alt="Photo" format="avif" quality={60} />Quality Settings
<!-- Lower quality, smaller file -->
<Image src={photo} alt="" quality="low" />
<Image src={photo} alt="" quality={50} />
<!-- Medium quality (default) -->
<Image src={photo} alt="" quality="mid" />
<Image src={photo} alt="" quality={75} />
<!-- Higher quality -->
<Image src={photo} alt="" quality="high" />
<Image src={photo} alt="" quality={90} />
<!-- Maximum quality -->
<Image src={photo} alt="" quality="max" />
<Image src={photo} alt="" quality={100} />Responsive Images
With Picture
<Picture
src={hero}
alt="Hero"
widths={[640, 768, 1024, 1280, 1536]}
sizes="(max-width: 640px) 640px, (max-width: 768px) 768px, (max-width: 1024px) 1024px, (max-width: 1280px) 1280px, 1536px"
formats={['avif', 'webp', 'jpg']}
/>With densities
<Image
src={logo}
alt="Logo"
width={200}
densities={[1, 2, 3]}
/>
<!-- Output srcset with 1x, 2x, 3x versions -->Configuration
Image Service
// astro.config.mjs
export default defineConfig({
image: {
// Sharp is default, or use custom service
service: {
entrypoint: 'astro/assets/services/sharp',
config: {
limitInputPixels: false,
},
},
// Allowed remote domains
domains: ['example.com', 'cdn.example.com'],
// Allowed remote patterns
remotePatterns: [
{
protocol: 'https',
hostname: '**.amazonaws.com',
},
],
},
});External Image Service
// astro.config.mjs
export default defineConfig({
image: {
service: {
entrypoint: '@astrojs/cloudinary',
config: {
cloudName: 'your-cloud-name',
},
},
},
});Markdown Images
Using Relative Paths
<!-- src/content/blog/post.md -->
Using Image Component in MDX
---
title: My Post
---
import { Image } from 'astro:assets';
import photo from './images/photo.jpg';
# My Post
<Image src={photo} alt="Photo" width={600} />
Regular markdown images still work:
Background Images
---
import { getImage } from 'astro:assets';
import bg from '../images/background.jpg';
const optimizedBg = await getImage({ src: bg, format: 'webp' });
---
<section class="hero" style={`background-image: url(${optimizedBg.src})`}>
<h1>Welcome</h1>
</section>
<style>
.hero {
background-size: cover;
background-position: center;
min-height: 100vh;
}
</style>Best Practices
1. Always use Image component for local images - Automatic optimization 2. Provide alt text - Accessibility requirement 3. Use Picture for hero images - Multiple formats and sizes 4. Specify width/height for CLS - Prevents layout shift 5. Use WebP/AVIF formats - Better compression 6. Configure remote domains - Security whitelist 7. Use quality settings appropriately - Balance size vs quality 8. Import images, don't use strings - Enables optimization 9. Use content collection image schema - Type-safe image handling
Middleware
Middleware intercepts requests and responses, allowing you to add logic before pages render.
Setup
Create src/middleware.ts (or .js):
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
// Run before the page renders
console.log('Request to:', context.url.pathname);
// Call next() to continue to the page/endpoint
const response = await next();
// Run after the page renders
console.log('Response status:', response.status);
return response;
});Context Object
export const onRequest = defineMiddleware(async (context, next) => {
// Request info
const url = context.url; // URL object
const pathname = url.pathname; // /about
const params = context.params; // { slug: 'hello' } for dynamic routes
const request = context.request; // Request object
// Cookies
const token = context.cookies.get('session')?.value;
context.cookies.set('visited', 'true', { path: '/' });
// Locals - share data with pages
context.locals.user = await getUser(token);
// Site info
const site = context.site; // From astro.config
const generator = context.generator; // "Astro vX.X.X"
// Redirect
if (!context.locals.user && pathname.startsWith('/dashboard')) {
return context.redirect('/login');
}
// Rewrite
if (pathname === '/old-page') {
return context.rewrite('/new-page');
}
return next();
});Authentication Example
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { verifyToken } from './lib/auth';
const protectedRoutes = ['/dashboard', '/settings', '/api/user'];
export const onRequest = defineMiddleware(async ({ cookies, url, locals, redirect }, next) => {
const isProtected = protectedRoutes.some(route =>
url.pathname.startsWith(route)
);
if (isProtected) {
const token = cookies.get('auth_token')?.value;
if (!token) {
return redirect('/login?redirect=' + encodeURIComponent(url.pathname));
}
try {
const user = await verifyToken(token);
locals.user = user;
} catch {
cookies.delete('auth_token');
return redirect('/login');
}
}
return next();
});Using Locals
Set in Middleware
// src/middleware.ts
export const onRequest = defineMiddleware(async ({ locals, cookies }, next) => {
const token = cookies.get('session')?.value;
locals.user = token ? await getUserFromToken(token) : null;
locals.theme = cookies.get('theme')?.value || 'light';
locals.requestTime = Date.now();
return next();
});Access in Pages
---
// src/pages/dashboard.astro
const { user, theme } = Astro.locals;
if (!user) {
return Astro.redirect('/login');
}
---
<h1>Welcome, {user.name}</h1>
<p>Theme: {theme}</p>Access in Endpoints
// src/pages/api/profile.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ locals }) => {
const { user } = locals;
if (!user) {
return new Response(null, { status: 401 });
}
return new Response(JSON.stringify(user));
};TypeScript Types
// src/env.d.ts
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
user: {
id: string;
email: string;
name: string;
} | null;
theme: 'light' | 'dark';
requestTime: number;
}
}Chaining Middleware
Use sequence() to run multiple middleware functions:
// src/middleware.ts
import { sequence } from 'astro:middleware';
const logging = defineMiddleware(async (context, next) => {
console.log(`[${new Date().toISOString()}] ${context.url.pathname}`);
return next();
});
const auth = defineMiddleware(async ({ cookies, locals }, next) => {
const token = cookies.get('session')?.value;
locals.user = token ? await verifyToken(token) : null;
return next();
});
const rateLimit = defineMiddleware(async ({ request, redirect }, next) => {
const ip = request.headers.get('x-forwarded-for');
if (await isRateLimited(ip)) {
return new Response('Too Many Requests', { status: 429 });
}
return next();
});
// Executes in order: logging → rateLimit → auth
export const onRequest = sequence(logging, rateLimit, auth);Modifying Responses
Add Headers
export const onRequest = defineMiddleware(async (context, next) => {
const response = await next();
// Add security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
return response;
});Modify Response Body
export const onRequest = defineMiddleware(async (context, next) => {
const response = await next();
// Only modify HTML responses
const contentType = response.headers.get('content-type');
if (!contentType?.includes('text/html')) {
return response;
}
const html = await response.text();
const modified = html.replace('</body>', '<script>/* injected */</script></body>');
return new Response(modified, {
status: response.status,
headers: response.headers,
});
});Redirects and Rewrites
Redirect
export const onRequest = defineMiddleware(async ({ url, redirect }, next) => {
// Redirect old URLs
if (url.pathname === '/old-blog') {
return redirect('/blog', 301); // Permanent redirect
}
// Temporary redirect
if (url.pathname === '/maintenance') {
return redirect('/coming-soon', 302);
}
return next();
});Rewrite
Serve different content for a URL without changing the URL:
export const onRequest = defineMiddleware(async ({ url, rewrite }, next) => {
// A/B testing
if (url.pathname === '/landing') {
const variant = Math.random() > 0.5 ? 'a' : 'b';
return rewrite(`/landing-${variant}`);
}
// Serve localized content
const locale = getLocaleFromHeaders();
if (url.pathname === '/about' && locale === 'es') {
return rewrite('/es/about');
}
return next();
});Error Handling
export const onRequest = defineMiddleware(async (context, next) => {
try {
return await next();
} catch (error) {
console.error('Middleware error:', error);
// Return error page
return new Response('Internal Server Error', {
status: 500,
headers: {
'Content-Type': 'text/plain',
},
});
}
});Common Patterns
CORS
export const onRequest = defineMiddleware(async ({ request, url }, next) => {
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
const response = await next();
// Add CORS headers to response
response.headers.set('Access-Control-Allow-Origin', '*');
return response;
});Request Timing
export const onRequest = defineMiddleware(async ({ url }, next) => {
const start = performance.now();
const response = await next();
const duration = performance.now() - start;
response.headers.set('X-Response-Time', `${duration.toFixed(2)}ms`);
console.log(`${url.pathname}: ${duration.toFixed(2)}ms`);
return response;
});Best Practices
1. Keep middleware fast - Runs on every request 2. Use locals for shared data - Cleaner than headers 3. Chain with sequence() - Organize separate concerns 4. Type your locals - Update env.d.ts 5. Handle errors gracefully - Don't expose stack traces 6. Use early returns - For redirects and error responses 7. Avoid heavy operations - Cache where possible
Routing
Astro uses file-based routing in the src/pages/ directory.
Basic Routes
src/pages/
├── index.astro → /
├── about.astro → /about
├── contact.astro → /contact
└── blog/
├── index.astro → /blog
└── post-1.astro → /blog/post-1Page Files
Supported Formats
.astro- Astro components.md- Markdown.mdx- MDX (with integration).html- Static HTML.js/.ts- Endpoints (API routes)
Basic Page
---
// src/pages/about.astro
import Layout from '../layouts/Layout.astro';
const title = "About Us";
---
<Layout title={title}>
<h1>{title}</h1>
<p>Welcome to our about page.</p>
</Layout>Dynamic Routes
Single Parameter
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<h1>{post.data.title}</h1>
<Content />Multiple Parameters
---
// src/pages/[category]/[slug].astro
export function getStaticPaths() {
return [
{ params: { category: 'tech', slug: 'astro-intro' } },
{ params: { category: 'life', slug: 'travel-tips' } },
];
}
const { category, slug } = Astro.params;
---
<p>Category: {category}, Slug: {slug}</p>Rest Parameters (Catch-all)
---
// src/pages/docs/[...path].astro
export function getStaticPaths() {
return [
{ params: { path: undefined } }, // /docs
{ params: { path: 'getting-started' } }, // /docs/getting-started
{ params: { path: 'guides/routing' } }, // /docs/guides/routing
];
}
const { path } = Astro.params;
// path can be undefined, "getting-started", or "guides/routing"
---Server-Side Routes (SSR)
With SSR enabled, you can access params without getStaticPaths:
---
// src/pages/products/[id].astro
// Requires output: 'server' or 'hybrid' with prerender = false
const { id } = Astro.params;
const response = await fetch(`https://api.example.com/products/${id}`);
const product = await response.json();
if (!product) {
return new Response(null, {
status: 404,
statusText: 'Not Found'
});
}
---
<h1>{product.name}</h1>Endpoints (API Routes)
Static Endpoints
// src/pages/api/posts.json.ts
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
export const GET: APIRoute = async () => {
const posts = await getCollection('blog');
return new Response(
JSON.stringify(posts.map(p => ({
title: p.data.title,
slug: p.slug,
}))),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
);
};Server Endpoints (SSR)
// src/pages/api/submit.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const formData = await request.formData();
const email = formData.get('email');
// Validate and process
if (!email) {
return new Response(
JSON.stringify({ error: 'Email required' }),
{ status: 400 }
);
}
// Save to database, send email, etc.
return new Response(
JSON.stringify({ success: true }),
{ status: 200 }
);
};Dynamic Endpoints
// src/pages/api/users/[id].ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ params }) => {
const { id } = params;
const user = await fetchUser(id);
if (!user) {
return new Response(null, { status: 404 });
}
return new Response(JSON.stringify(user));
};
export const DELETE: APIRoute = async ({ params }) => {
const { id } = params;
await deleteUser(id);
return new Response(null, { status: 204 });
};Redirects
In Configuration
// astro.config.mjs
export default defineConfig({
redirects: {
'/old-page': '/new-page',
'/blog/[...slug]': '/articles/[...slug]',
'/external': 'https://example.com',
},
});Programmatic Redirects (SSR)
---
// In page or middleware
if (!user) {
return Astro.redirect('/login');
}
// With status code
return Astro.redirect('/dashboard', 307);
---Rewrites
Serve different content for a URL without redirect:
---
// src/pages/[lang]/about.astro
const { lang } = Astro.params;
if (lang !== 'en' && lang !== 'es') {
return Astro.rewrite('/404');
}
---Route Priority
When multiple routes could match, Astro uses this priority:
1. Static routes (/about) 2. Dynamic routes with named params (/blog/[slug]) 3. Rest parameters (/[...path])
/posts/create → src/pages/posts/create.astro (static wins)
/posts/hello-world → src/pages/posts/[slug].astro (dynamic)
/posts/2024/01/15 → src/pages/posts/[...slug].astro (rest)Pagination
---
// src/pages/blog/[...page].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths({ paginate }) {
const posts = await getCollection('blog');
const sortedPosts = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
return paginate(sortedPosts, { pageSize: 10 });
}
const { page } = Astro.props;
---
<ul>
{page.data.map((post) => (
<li>{post.data.title}</li>
))}
</ul>
<nav>
{page.url.prev && <a href={page.url.prev}>Previous</a>}
<span>Page {page.currentPage} of {page.lastPage}</span>
{page.url.next && <a href={page.url.next}>Next</a>}
</nav>i18n Routing
// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
routing: {
prefixDefaultLocale: false, // /about vs /en/about
},
},
});src/pages/
├── index.astro → /
├── about.astro → /about
├── es/
│ ├── index.astro → /es
│ └── about.astro → /es/about
└── fr/
├── index.astro → /fr
└── about.astro → /fr/aboutBest Practices
1. Use content collections for dynamic content - Type safety and validation 2. Keep API endpoints RESTful - Clear HTTP methods and paths 3. Use rest params for catch-all - [...slug] for flexible paths 4. Configure redirects in config - Better than manual redirects 5. Test route priority - Ensure expected routes win 6. Use pagination for large lists - Better performance and UX
Server Islands
Server islands allow you to defer rendering of specific Astro components to the server, loading them independently from the rest of the page. This keeps your main content fast while dynamic/personalized sections load separately.
How It Works
1. The page renders immediately with fallback content as placeholder 2. Each server:defer component is fetched via a separate request 3. The component's HTML replaces the fallback when ready 4. Each island loads independently — a slow island won't block others
Basic Usage
Add server:defer to any Astro component to turn it into a server island:
---
import Avatar from '../components/Avatar.astro';
import ProductReviews from '../components/ProductReviews.astro';
---
<!-- Static content renders immediately -->
<h1>Product Page</h1>
<p>This content is fast and cacheable.</p>
<!-- Server island: deferred rendering -->
<Avatar server:defer />
<!-- Server island with fallback -->
<ProductReviews server:defer>
<div slot="fallback">
<p>Loading reviews...</p>
</div>
</ProductReviews>Fallback Content
Use the named "fallback" slot to show placeholder content while the island loads:
<Avatar server:defer>
<!-- Generic placeholder shown until real avatar loads -->
<img slot="fallback" src="/generic-avatar.svg" alt="Loading..." />
</Avatar>Good fallback patterns:
- Generic placeholder (e.g., generic avatar instead of user's avatar)
- Loading skeleton/spinner
- Placeholder UI with approximate dimensions (prevents layout shift)
Server Island Components
Inside a server island component, you can do anything a normal SSR component can:
---
// src/components/Avatar.astro
// This runs on the server when the island is requested
const userSession = Astro.cookies.get('session');
const avatarURL = await getUserAvatar(userSession);
---
<img alt="User avatar" src={avatarURL} />Props
Props passed to server islands must be serializable — they're encoded in the request URL.
Supported types: plain objects, number, string, Array, Map, Set, RegExp, Date, BigInt, URL, Uint8Array, Uint16Array, Uint32Array, Infinity
NOT supported: functions, class instances, circular references
<!-- OK: serializable props -->
<ProductCard server:defer productId="abc-123" />
<UserBadge server:defer userId={42} showAvatar={true} />
<!-- NOT OK: functions can't be serialized -->
<Component server:defer onClick={handleClick} />Keep props small. Props are encoded in the URL query string. If the URL exceeds ~2048 bytes, Astro switches to a POST request which browsers don't cache. Pass IDs rather than full data objects.
Accessing the Page URL
Server islands run in their own isolated context. Astro.url returns the island's internal URL (e.g., /_server-islands/Avatar), not the page URL.
To access the page URL, check the Referer header:
---
const referer = Astro.request.headers.get('Referer');
const url = new URL(referer);
const productId = url.searchParams.get('product');
---Caching
Server island data is fetched via GET requests, so standard Cache-Control headers work:
---
// Cache this island for 1 hour
Astro.response.headers.set('Cache-Control', 'max-age=3600');
---Requirements
- An adapter must be installed (Node, Vercel, Netlify, Cloudflare, etc.)
- Server islands work in both
serverandhybridoutput modes - The page containing the island can be prerendered — only the island itself needs server rendering
Use Cases
Server islands are ideal for:
- Personalized content on otherwise static pages (user avatars, greeting bars)
- Dynamic data that changes frequently (product prices, stock status, reviews)
- Authenticated sections (user dashboards widgets on a public page)
- Slow data sources that would delay the whole page if rendered inline
Expert Patterns
The E-Commerce Page Pattern
The most common server island pattern — three rendering strategies on one page:
---
import PriceStock from '../components/PriceStock.astro';
import AddToCart from '../components/AddToCart.jsx';
---
<!-- Static: title, images, description (cached at CDN) -->
<h1>{product.title}</h1>
<img src={product.image} alt={product.title} />
<p>{product.description}</p>
<!-- Server island: price/stock changes often, needs server data -->
<PriceStock server:defer productId={product.id}>
<div slot="fallback" class="price-skeleton" />
</PriceStock>
<!-- Client island: add-to-cart needs onClick/state -->
<AddToCart client:load productId={product.id} />Common Mistakes
Passing large objects as props:
<!-- BAD: entire product object gets URL-encoded -->
<PriceStock server:defer product={fullProductObject} />
<!-- GOOD: pass only the ID, fetch inside the island -->
<PriceStock server:defer productId={product.id} />If serialized props exceed ~2048 bytes, Astro switches from GET to POST — losing browser/CDN caching entirely.
Forgetting the fallback slot: Without slot="fallback", users see nothing until the island loads. Always provide a placeholder that matches the island's dimensions to prevent layout shift.
Using `Astro.url` instead of `Referer`: Astro.url inside a server island returns /_server-islands/ComponentName, not the page URL. This is a frequent bug source. Always use the Referer header:
---
// WRONG: returns /_server-islands/PriceStock
const url = Astro.url;
// RIGHT: returns the actual page URL
const pageUrl = new URL(Astro.request.headers.get('Referer'));
---When NOT to Use Server Islands
- Data that doesn't change per-request → Use static rendering with rebuild triggers instead
- Components needing fast interactivity (clicks, hover effects) → Use
client:*directives - Nested server islands → Not supported; flatten your component hierarchy
- Components depending on page layout context → Server islands render in isolation; they can't access parent component state
Encryption Key for Deployments
Props are encrypted. In rolling deployments or multi-region setups where frontend/backend may use different keys:
astro create-keySet the result as ASTRO_KEY environment variable in your build environment to keep encryption in sync.
Sessions
Sessions store data on the server between requests for on-demand rendered pages. Unlike cookies, sessions have no size limits and are more secure since data never leaves the server.
Added in: Astro 5.7+
Setup
Sessions require a storage driver. Some adapters (Node, Cloudflare, Netlify) configure a default driver automatically.
// astro.config.mjs
import { defineConfig, sessionDrivers } from 'astro/config';
import vercel from '@astrojs/vercel';
export default defineConfig({
adapter: vercel(),
session: {
driver: sessionDrivers.redis({
url: process.env.REDIS_URL,
}),
},
});Any unstorage driver can be used (Redis, filesystem, memory, etc.).
Using Sessions
In Astro Components and Pages
---
export const prerender = false; // Required in hybrid mode
const cart = await Astro.session?.get('cart');
---
<a href="/checkout">Cart: {cart?.length ?? 0} items</a>In API Endpoints
// src/pages/api/addToCart.ts
export async function POST(context: APIContext) {
const cart = await context.session?.get('cart') || [];
const data = await context.request.json<{ item: string }>();
if (!data?.item) {
return new Response('Item is required', { status: 400 });
}
cart.push(data.item);
await context.session?.set('cart', cart);
return Response.json(cart);
}In Actions
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';
export const server = {
addToCart: defineAction({
input: z.object({ productId: z.string() }),
handler: async (input, context) => {
const cart = await context.session?.get('cart') || [];
cart.push(input.productId);
await context.session?.set('cart', cart);
return cart;
},
}),
};In Middleware
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
context.session?.set('lastVisit', new Date());
return next();
});Note: Sessions are not supported in edge middleware.
Session API
| Method | Description |
|---|---|
session.get(key) | Get a value by key |
session.set(key, value) | Set a value |
session.regenerate() | Create a new session ID (use after login) |
session.destroy() | Delete the entire session (use on logout) |
Type Safety
Define session data types in src/env.d.ts:
declare namespace App {
interface SessionData {
user: {
id: string;
name: string;
};
cart: string[];
lastVisit: Date;
}
}This enables type-checking and autocomplete:
---
const cart = await Astro.session?.get('cart');
// const cart: string[] | undefined
Astro.session?.set('user', { id: 1, name: 'Houston' });
// Error: id should be string, not number
---Supported Data Types
Session values are serialized with devalue. Supported types: strings, numbers, Date, Map, Set, URL, arrays, and plain objects.
Best Practices
1. Always use optional chaining (session?.get()) — session may be undefined if not configured 2. Regenerate after login — prevents session fixation attacks 3. Destroy on logout — cleans up server-side data 4. Keep session data small — store IDs and references, not large objects 5. Type your session data — use App.SessionData for safety 6. Don't rely on sessions for prerendered pages — sessions only work with on-demand rendering
SSR & Adapters
Astro supports on-demand server rendering with various deployment adapters.
Output Modes
Static (Default)
// astro.config.mjs
export default defineConfig({
output: 'static', // Default - all pages prerendered
});All pages built at build time. No server required.
Server (Full SSR)
// astro.config.mjs
import node from '@astrojs/node';
export default defineConfig({
output: 'server', // All pages rendered on-demand
adapter: node({
mode: 'standalone',
}),
});All pages rendered per-request. Requires an adapter.
Hybrid
// astro.config.mjs
import vercel from '@astrojs/vercel';
export default defineConfig({
output: 'hybrid', // Static by default, opt-in to SSR
adapter: vercel(),
});Static by default, with option to make specific pages dynamic.
Opting In/Out of Prerendering
In Hybrid Mode (opt-out of prerendering)
---
// src/pages/api/time.ts
export const prerender = false; // Server-rendered
export const GET = () => {
return new Response(new Date().toISOString());
};
---In Server Mode (opt-in to prerendering)
---
// src/pages/about.astro
export const prerender = true; // Static at build time
---
<h1>About Us</h1>Available Adapters
Node.js
npx astro add node// astro.config.mjs
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone', // or 'middleware'
}),
});Vercel
npx astro add vercel// astro.config.mjs
import vercel from '@astrojs/vercel';
export default defineConfig({
output: 'server',
adapter: vercel({
webAnalytics: { enabled: true },
imageService: true,
}),
});Netlify
npx astro add netlify// astro.config.mjs
import netlify from '@astrojs/netlify';
export default defineConfig({
output: 'server',
adapter: netlify({
edgeMiddleware: true, // Use Edge Functions
}),
});Cloudflare
npx astro add cloudflare// astro.config.mjs
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server',
adapter: cloudflare({
mode: 'directory', // or 'advanced'
routes: {
strategy: 'include',
include: ['/api/*'],
},
}),
});Deno
npx astro add deno// astro.config.mjs
import deno from '@astrojs/deno';
export default defineConfig({
output: 'server',
adapter: deno(),
});SSR Features
Request Object
---
// Available in SSR pages
const url = Astro.url;
const method = Astro.request.method;
const headers = Astro.request.headers;
const userAgent = headers.get('user-agent');
// Get request body (POST, PUT, etc.)
if (method === 'POST') {
const formData = await Astro.request.formData();
const json = await Astro.request.json();
}
---Cookies
---
// Reading cookies
const sessionId = Astro.cookies.get('session')?.value;
const prefs = Astro.cookies.get('prefs')?.json();
// Setting cookies
Astro.cookies.set('session', 'abc123', {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 1 week
});
// Deleting cookies
Astro.cookies.delete('session');
---Response Headers
---
Astro.response.headers.set('Cache-Control', 'max-age=3600');
Astro.response.headers.set('X-Custom-Header', 'value');
---Redirects
---
if (!user) {
return Astro.redirect('/login', 302);
}
---Server Islands
Defer rendering of specific Astro components to the server. Each island loads independently, keeping main content fast. No experimental flag needed in Astro 5+.
---
import UserProfile from '../components/UserProfile.astro';
import ProductReviews from '../components/ProductReviews.astro';
---
<!-- Static content renders immediately -->
<h1>Welcome</h1>
<!-- Server-rendered on each request -->
<UserProfile server:defer>
<p slot="fallback">Loading profile...</p>
</UserProfile>
<!-- Multiple islands load in parallel -->
<ProductReviews server:defer productId="abc-123">
<div slot="fallback">Loading reviews...</div>
</ProductReviews>Key points:
- Requires an adapter (same as SSR)
- Props must be serializable (no functions)
- Use
Refererheader to access page URL inside island - See references/server-islands.md for full details
Sessions
Server-side session storage for on-demand rendered pages (Astro 5.7+):
// astro.config.mjs
import { defineConfig, sessionDrivers } from 'astro/config';
export default defineConfig({
adapter: node({ mode: 'standalone' }),
session: {
driver: sessionDrivers.redis({ url: process.env.REDIS_URL }),
},
});---
export const prerender = false;
const cart = await Astro.session?.get('cart');
await Astro.session?.set('lastVisit', new Date());
---See references/sessions.md for full details.
API Endpoints
GET, POST, PUT, DELETE
// src/pages/api/users/[id].ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ params, request }) => {
const { id } = params;
const user = await db.users.findById(id);
if (!user) {
return new Response(null, { status: 404 });
}
return new Response(JSON.stringify(user), {
headers: { 'Content-Type': 'application/json' },
});
};
export const PUT: APIRoute = async ({ params, request }) => {
const { id } = params;
const data = await request.json();
await db.users.update(id, data);
return new Response(null, { status: 204 });
};
export const DELETE: APIRoute = async ({ params }) => {
const { id } = params;
await db.users.delete(id);
return new Response(null, { status: 204 });
};Streaming Responses
// src/pages/api/stream.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${i}\n\n`));
await new Promise(r => setTimeout(r, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
};Context and Locals
Access shared data across middleware and pages:
// src/middleware.ts
export const onRequest = async ({ locals, request }, next) => {
const token = request.headers.get('authorization');
locals.user = await validateToken(token);
return next();
};---
// src/pages/dashboard.astro
const { user } = Astro.locals;
if (!user) {
return Astro.redirect('/login');
}
---
<h1>Welcome, {user.name}</h1>Best Practices
1. Use hybrid mode - Static by default, SSR only where needed 2. Prerender where possible - Better performance and caching 3. Use appropriate adapter - Match your deployment platform 4. Handle errors gracefully - Return proper status codes 5. Set cache headers - Control CDN and browser caching 6. Validate user input - Never trust request data 7. Use locals for shared state - Cleaner than passing through props
Styling
Astro supports various styling approaches with scoped styles as the default.
Scoped Styles (Default)
Styles in <style> tags are automatically scoped to the component:
---
// Component.astro
---
<div class="container">
<h1>Hello World</h1>
</div>
<style>
/* Only affects this component */
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
color: navy;
}
</style>Astro adds unique class attributes to scope styles:
<!-- Output -->
<div class="container astro-J7PV25F6">
<h1 class="astro-J7PV25F6">Hello World</h1>
</div>Global Styles
:global() Selector
Target elements globally within a scoped style block:
<style>
/* Scoped to component */
.container {
padding: 1rem;
}
/* Global - affects all h1 elements */
:global(h1) {
font-size: 2rem;
}
/* Global within scoped context */
.container :global(p) {
line-height: 1.6;
}
</style>is:global Attribute
Make entire style block global:
<style is:global>
/* All styles here are global */
body {
font-family: system-ui, sans-serif;
}
a {
color: blue;
}
</style>Global Stylesheets
---
// src/layouts/Layout.astro
import '../styles/global.css';
---
<!doctype html>
<html>
<body>
<slot />
</body>
</html>/* src/styles/global.css */
:root {
--color-primary: #3b82f6;
--color-text: #1f2937;
}
body {
color: var(--color-text);
line-height: 1.5;
}CSS Variables
Defining Variables
<style>
:root {
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 2rem;
}
.card {
padding: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
</style>Dynamic Variables with define:vars
Pass JavaScript values to CSS:
---
const theme = {
primaryColor: '#3b82f6',
fontSize: '16px',
};
const backgroundUrl = '/images/hero.jpg';
---
<div class="hero">
<h1>Welcome</h1>
</div>
<style define:vars={{
primaryColor: theme.primaryColor,
fontSize: theme.fontSize,
bgImage: `url(${backgroundUrl})`
}}>
.hero {
background-image: var(--bgImage);
color: var(--primaryColor);
font-size: var(--fontSize);
}
</style>class:list Directive
Conditionally apply classes:
---
const isActive = true;
const isDisabled = false;
const size = 'large';
---
<button class:list={[
'btn', // Always applied
{ 'btn-active': isActive }, // Applied if true
{ 'btn-disabled': isDisabled }, // Not applied (false)
size && `btn-${size}`, // "btn-large"
['extra', 'classes'], // Arrays flattened
]}>
Click Me
</button>
<!-- Output: <button class="btn btn-active btn-large extra classes">Click Me</button> -->CSS Preprocessors
Sass/SCSS
npm install sass<style lang="scss">
$primary: #3b82f6;
$spacing: 1rem;
.card {
padding: $spacing;
border: 1px solid lighten($primary, 30%);
&:hover {
border-color: $primary;
}
.title {
color: $primary;
font-weight: bold;
}
}
</style>Less
npm install less<style lang="less">
@primary: #3b82f6;
.button {
background: @primary;
&:hover {
background: darken(@primary, 10%);
}
}
</style>Stylus
npm install stylus<style lang="stylus">
primary = #3b82f6
.container
max-width 800px
margin 0 auto
</style>Tailwind CSS
Setup
npx astro add tailwindUsage
---
// No style tag needed
---
<div class="max-w-4xl mx-auto p-4">
<h1 class="text-3xl font-bold text-blue-600">
Hello World
</h1>
<p class="mt-4 text-gray-700 leading-relaxed">
Welcome to my site.
</p>
</div>With class:list
---
const isLarge = true;
---
<button class:list={[
'px-4 py-2 rounded',
'bg-blue-500 hover:bg-blue-600',
'text-white font-medium',
{ 'text-lg': isLarge },
]}>
Click Me
</button>External Stylesheets
Link in Head
---
// src/layouts/Layout.astro
---
<html>
<head>
<link rel="stylesheet" href="/styles/global.css" />
<link rel="stylesheet" href="https://cdn.example.com/library.css" />
</head>
<body>
<slot />
</body>
</html>Import in Component
---
import '../styles/component.css';
import 'package/styles.css';
---CSS Modules
Astro supports CSS Modules for component-scoped styles:
---
import styles from './Button.module.css';
---
<button class={styles.button}>
<span class={styles.icon}>+</span>
Click Me
</button>/* Button.module.css */
.button {
padding: 0.5rem 1rem;
background: blue;
}
.icon {
margin-right: 0.5rem;
}PostCSS
Create postcss.config.mjs:
// postcss.config.mjs
export default {
plugins: {
autoprefixer: {},
'postcss-nesting': {},
},
};Then use modern CSS features:
<style>
.card {
& .title {
font-weight: bold;
}
&:hover {
background: #f0f0f0;
}
}
</style>Style Inheritance
Styles don't cascade into child components:
<!-- Parent.astro -->
<div class="parent">
<Child />
</div>
<style>
.parent p {
color: red; /* Does NOT affect p inside Child */
}
</style>To style child content, use :global():
<style>
.parent :global(p) {
color: red; /* Affects all nested p elements */
}
</style>Passing Classes to Components
Accepting className prop
---
// Button.astro
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<button class:list={['btn', className]}>
<slot />
</button>
<style>
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
}
</style><!-- Usage -->
<Button class="mt-4 custom-class">Click Me</Button>Best Practices
1. Use scoped styles by default - Prevents style conflicts 2. Use :global() sparingly - Only when necessary 3. Use CSS variables for theming - Easy to change globally 4. Use define:vars for dynamic styles - Pass JS values to CSS 5. Use class:list for conditional classes - Cleaner than ternaries 6. Keep global styles minimal - Reset, typography, utilities only 7. Use preprocessors if needed - Sass for complex styling 8. Consider Tailwind for utility-first - Rapid development
View Transitions
Astro's View Transitions provide smooth navigation between pages without full page reloads.
Setup
---
// src/layouts/Layout.astro
import { ClientRouter } from 'astro:transitions';
---
<!doctype html>
<html>
<head>
<ClientRouter />
</head>
<body>
<slot />
</body>
</html>How It Works
With <ClientRouter /> enabled: 1. User clicks a link 2. Astro intercepts the navigation 3. New page content is fetched 4. Transitions animate between old and new content 5. Browser history is updated
Built-in Animations
fade (Default)
---
import { fade } from 'astro:transitions';
---
<div transition:animate={fade({ duration: '0.4s' })}>
Content fades in and out
</div>slide
---
import { slide } from 'astro:transitions';
---
<div transition:animate={slide({ duration: '0.3s' })}>
Content slides in from the side
</div>initial
Prevents animation on first page load:
<div transition:animate="initial">
No animation on initial load
</div>none
Disables transition for an element:
<div transition:animate="none">
Instant swap, no animation
</div>Transition Directives
transition:name
Link elements across pages for morphing:
<!-- Page 1: List -->
<img
src={post.image}
transition:name={`hero-${post.slug}`}
/>
<!-- Page 2: Detail -->
<img
src={post.data.image}
transition:name={`hero-${post.slug}`}
/>Elements with matching transition:name will morph into each other.
transition:animate
Control animation behavior:
<header transition:animate="none">
<!-- Static header, no animation -->
</header>
<main transition:animate={slide({ duration: '0.5s' })}>
<!-- Slides in -->
</main>transition:persist
Keep element state across navigations:
<!-- Video keeps playing during navigation -->
<video transition:persist autoplay>
<source src="video.mp4" />
</video>
<!-- Form keeps user input -->
<form transition:persist>
<input type="text" />
</form>
<!-- React component maintains state -->
<Counter client:load transition:persist />With unique ID:
<audio transition:persist="player" controls>
<source src="song.mp3" />
</audio>Custom Animations
Define with keyframes
---
import { slide } from 'astro:transitions';
---
<style>
@keyframes slideInFromLeft {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOutToRight {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
</style>
<div
transition:animate={{
old: {
name: 'slideOutToRight',
duration: '0.3s',
easing: 'ease-in',
},
new: {
name: 'slideInFromLeft',
duration: '0.3s',
easing: 'ease-out',
},
}}
>
Custom sliding content
</div>Reusable Animation
// src/transitions/custom.ts
export const customSlide = {
old: {
name: 'slideOut',
duration: '0.3s',
easing: 'ease-in',
fillMode: 'forwards',
},
new: {
name: 'slideIn',
duration: '0.3s',
easing: 'ease-out',
fillMode: 'backwards',
},
};---
import { customSlide } from '../transitions/custom';
---
<div transition:animate={customSlide}>
Uses custom animation
</div>Navigation Controls
Programmatic Navigation
<script>
import { navigate } from 'astro:transitions/client';
// Navigate programmatically
navigate('/about');
// With history options
navigate('/dashboard', { history: 'replace' });
</script>Prevent Navigation
<a href="/external" data-astro-reload>
Full page reload
</a>
<form data-astro-reload>
Submit causes full reload
</form>Lifecycle Events
<script>
document.addEventListener('astro:before-preparation', (event) => {
// Before fetching new page
console.log('Navigating to:', event.to);
});
document.addEventListener('astro:after-preparation', (event) => {
// After fetching, before swap
});
document.addEventListener('astro:before-swap', (event) => {
// Right before DOM swap
// Can customize swap behavior
event.swap = () => {
// Custom swap logic
};
});
document.addEventListener('astro:after-swap', (event) => {
// After DOM swap, before animations
// Good for re-initializing scripts
});
document.addEventListener('astro:page-load', (event) => {
// Page fully loaded, animations complete
// Runs on initial load and every navigation
});
</script>Script Re-execution
Scripts with data-astro-rerun execute on every navigation:
<script data-astro-rerun>
// This runs on initial load AND every navigation
console.log('Page changed!');
initializeComponent();
</script>Form Handling
Forms work with view transitions:
<form method="POST" action="/api/submit">
<input type="text" name="email" />
<button type="submit">Subscribe</button>
</form>
<!-- Disable transitions for form -->
<form data-astro-reload method="POST">
<!-- Full page reload on submit -->
</form>Fallback Behavior
Browsers without View Transitions API get:
- Full page navigation (no JavaScript errors)
- Graceful degradation
Check support:
<script>
if (document.startViewTransition) {
console.log('View Transitions supported!');
}
</script>Configuration
Disable for Specific Links
<!-- Skip view transitions -->
<a href="/page" data-astro-reload>Full Reload</a>Prefetching
// astro.config.mjs
export default defineConfig({
prefetch: {
prefetchAll: true, // Prefetch all links on hover
defaultStrategy: 'viewport', // or 'hover', 'load'
},
});<!-- Manual prefetch control -->
<a href="/page" data-astro-prefetch="hover">Prefetch on hover</a>
<a href="/page" data-astro-prefetch="viewport">Prefetch when visible</a>
<a href="/page" data-astro-prefetch="load">Prefetch immediately</a>
<a href="/page" data-astro-prefetch="false">Never prefetch</a>Common Patterns
Persistent Navigation
<!-- Navigation stays in place -->
<nav transition:persist>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<!-- Main content transitions -->
<main transition:animate={fade()}>
<slot />
</main>Image Gallery
<!-- List page -->
{images.map((img) => (
<a href={`/gallery/${img.id}`}>
<img
src={img.thumb}
transition:name={`image-${img.id}`}
/>
</a>
))}
<!-- Detail page -->
<img
src={image.full}
transition:name={`image-${image.id}`}
/>Best Practices
1. Use transition:name for connected elements - Creates smooth morphing 2. Keep heavy components persistent - Video players, iframes 3. Re-run initialization scripts - Use data-astro-rerun 4. Handle loading states - Show indicators during fetching 5. Test without JavaScript - Ensure graceful fallback 6. Use prefetching wisely - Balance speed vs bandwidth 7. Avoid animating too many elements - Can impact performance
Astro Component Rules
Component Structure
Always use the frontmatter pattern with proper separation:
---
// 1. Imports
import Layout from '../layouts/Layout.astro';
import { getCollection } from 'astro:content';
// 2. Props interface
interface Props {
title: string;
description?: string;
}
// 3. Destructure props with defaults
const { title, description = 'Default' } = Astro.props;
// 4. Data fetching and logic
const posts = await getCollection('blog');
---
<!-- 5. Template -->
<Layout title={title}>
<h1>{title}</h1>
</Layout>
<!-- 6. Scoped styles -->
<style>
h1 { color: navy; }
</style>MUST DO
- Define
interface Propsfor type safety - Use destructuring with defaults for optional props
- Keep frontmatter logic minimal and focused
- Use slots for composable content
- Use
class:listfor conditional classes - Import components and assets at the top
MUST NOT DO
- Access browser APIs (window, document) in frontmatter - runs on server
- Use side effects in frontmatter - runs on every render
- Mix UI framework components without client directives
- Forget alt text on images
- Use inline styles when scoped styles work
- Skip TypeScript interfaces for Props
Slots Pattern
---
// Wrapper.astro
interface Props {
title: string;
}
const { title } = Astro.props;
const hasFooter = Astro.slots.has('footer');
---
<article>
<header><h1>{title}</h1></header>
<main><slot /></main>
{hasFooter && <footer><slot name="footer" /></footer>}
</article>Dynamic Attributes
---
const id = "main";
const isActive = true;
---
<div
id={id}
class:list={['base', { active: isActive }]}
data-active={isActive}
>
Content
</div>Conditional Rendering
---
const show = true;
const items = ['a', 'b', 'c'];
---
{show && <p>Visible</p>}
{show ? <p>Yes</p> : <p>No</p>}
<ul>
{items.map(item => <li>{item}</li>)}
</ul>Astro Image Rules
Image Component
---
import { Image } from 'astro:assets';
import heroImage from '../images/hero.jpg';
---
<Image
src={heroImage}
alt="Descriptive alt text"
width={800}
height={600}
format="webp"
quality={80}
/>MUST DO
- Import local images - enables optimization
- Always provide meaningful alt text
- Use
<Image>component for local images - Use
<Picture>for responsive hero images - Specify width/height to prevent layout shift
- Configure allowed domains for remote images
- Use the image() schema helper in content collections
MUST NOT DO
- Use string paths for local images:
src="/images/hero.jpg" - Skip alt text (accessibility requirement)
- Use
<img>tags for local images (misses optimization) - Forget width/height for remote images
- Allow arbitrary remote domains
Local vs Remote Images
---
import { Image } from 'astro:assets';
import localImage from '../images/photo.jpg';
---
<!-- Local: import required, auto-optimized -->
<Image src={localImage} alt="Local photo" />
<!-- Remote: dimensions required -->
<Image
src="https://example.com/photo.jpg"
alt="Remote photo"
width={400}
height={300}
/>
<!-- Remote with inferred size (fetches image) -->
<Image
src="https://example.com/photo.jpg"
alt="Remote photo"
inferSize
/>Picture for Responsive Images
---
import { Picture } from 'astro:assets';
import hero from '../images/hero.jpg';
---
<Picture
src={hero}
alt="Hero image"
formats={['avif', 'webp', 'jpg']}
widths={[400, 800, 1200]}
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
/>Content Collections with Images
// src/content/config.ts
const blog = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
title: z.string(),
cover: image(), // Validates and optimizes
coverAlt: z.string(),
}),
});---
title: My Post
cover: ./images/cover.jpg
coverAlt: Post cover showing...
---Background Images
---
import { getImage } from 'astro:assets';
import bg from '../images/background.jpg';
const optimizedBg = await getImage({
src: bg,
format: 'webp',
width: 1920,
});
---
<section style={`background-image: url(${optimizedBg.src})`}>
Content
</section>Configuration
// astro.config.mjs
export default defineConfig({
image: {
domains: ['cdn.example.com'],
remotePatterns: [
{ protocol: 'https', hostname: '**.unsplash.com' },
],
},
});Quality Settings
quality="low"orquality={50}- Smaller filesquality="mid"orquality={75}- Default balancequality="high"orquality={90}- Better qualityquality="max"orquality={100}- Maximum quality
Astro Routing Rules
File-Based Routing
src/pages/
├── index.astro → /
├── about.astro → /about
├── blog/
│ ├── index.astro → /blog
│ └── [slug].astro → /blog/:slug
├── [...path].astro → /* (catch-all)
└── api/
└── posts.json.ts → /api/posts.jsonMUST DO
- Use
getStaticPaths()for dynamic routes in static mode - Return proper status codes from API endpoints
- Validate params in SSR routes
- Use content collections for dynamic content pages
- Handle 404 cases explicitly
MUST NOT DO
- Access
Astro.requestin prerendered pages - Forget
getStaticPaths()in static output mode - Return sensitive data in API responses without auth
- Use interactive flags like
git rebase -iin routes
Dynamic Routes Pattern
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>Rest Parameters (Catch-All)
---
// src/pages/docs/[...path].astro
export function getStaticPaths() {
return [
{ params: { path: undefined } }, // /docs
{ params: { path: 'intro' } }, // /docs/intro
{ params: { path: 'guides/start' } }, // /docs/guides/start
];
}
const { path } = Astro.params;
---API Endpoints
// src/pages/api/posts.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ params, request }) => {
const data = await fetchPosts();
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
if (!body.title) {
return new Response(JSON.stringify({ error: 'Title required' }), {
status: 400,
});
}
const post = await createPost(body);
return new Response(JSON.stringify(post), { status: 201 });
};Pagination
---
// src/pages/blog/[...page].astro
export async function getStaticPaths({ paginate }) {
const posts = await getCollection('blog');
return paginate(posts.sort((a, b) =>
b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
), { pageSize: 10 });
}
const { page } = Astro.props;
---
<ul>
{page.data.map(post => <li>{post.data.title}</li>)}
</ul>
{page.url.prev && <a href={page.url.prev}>Previous</a>}
{page.url.next && <a href={page.url.next}>Next</a>}Redirects
// astro.config.mjs
export default defineConfig({
redirects: {
'/old': '/new',
'/blog/[...slug]': '/posts/[...slug]',
},
});Related skills
How it compares
Pick astro-framework over generic frontend skills when the codebase uses Astro 5.x islands, content collections, or server:defer patterns.
FAQ
Who is astro-framework for?
Developers and software engineers working with astro-framework patterns described in the skill documentation.
When should I use astro-framework?
When Astro framework specialist for building fast, content-driven websites with islands architecture. Use when creating Astro components, configuring hydration client:load/idle/visible/.
Is astro-framework safe to install?
Review the Security Audits panel on this page before installing in production.