astro-ops
Astro framework patterns, islands architecture, content collections, rendering strategies, and deployment. Use for: astro, islands architecture, content collections, astro cloudflare, view transitions, partial hydration, astrojs, SSG, SSR, hybrid rendering, astro adapter.
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/astro-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Astro Operations
Facts verified as of 2026-07.
Comprehensive patterns for Astro framework development: islands architecture, content collections, rendering strategies, view transitions, and multi-platform deployment.
Rendering Strategy Decision Tree
Which rendering strategy?
│
├─ Is content mostly static (blog, docs, marketing)?
│ ├─ YES → Does it change less than daily?
│ │ ├─ YES → SSG (output: 'static')
│ │ │ Fastest TTFB, CDN-cacheable, zero runtime cost
│ │ └─ NO → Hybrid (output: 'hybrid')
│ │ Default static + opt-in SSR per route
│ └─ NO → Does every page need personalization?
│ ├─ YES → SSR (output: 'server')
│ │ Dynamic per-request, auth-aware, real-time data
│ └─ NO → Hybrid (output: 'hybrid')
│ Static shell + server islands for dynamic parts
│
├─ Does the app need real-time interactivity (dashboard, SPA)?
│ ├─ YES → Is it a full SPA with client-side routing?
│ │ ├─ YES → Consider React/Vue SPA instead, or Astro + client:only
│ │ └─ NO → Hybrid + islands architecture
│ │ Interactive islands in static pages
│ └─ NO → SSG (output: 'static')
│
├─ Build time concerns (>10k pages)?
│ ├─ YES → Hybrid with on-demand rendering
│ │ Prerender popular pages, SSR the long tail
│ └─ NO → SSG handles it fine
│
└─ Need edge computing (low latency globally)?
├─ YES → SSR + Cloudflare/Vercel Edge adapter
└─ NO → SSR + Node adapter or SSG
Configuration
// astro.config.mjs
import { defineConfig } from 'astro/config';
// SSG (default) - all pages prerendered at build time
export default defineConfig({
output: 'static',
});
// SSR - all pages rendered on request
export default defineConfig({
output: 'server',
adapter: cloudflare(), // or vercel(), netlify(), node()
});
// Hybrid - static default, opt-in SSR per page
export default defineConfig({
output: 'hybrid',
adapter: cloudflare(),
});
---
// In hybrid mode, opt OUT of prerendering for specific pages:
export const prerender = false;
// In SSR mode, opt IN to prerendering:
export const prerender = true;
---
Islands Architecture Quick Reference
| Directive | Hydrates When | JS Shipped | Use Case |
|---|---|---|---|
client:load |
Immediately on page load | Full bundle | Above-fold interactive (nav, hero CTA) |
client:idle |
After page is idle (requestIdleCallback) |
Full bundle | Below-fold interactive (comment form, chat) |
client:visible |
When scrolled into viewport | Full bundle | Far-down-page (footer widget, carousel) |
client:media |
When media query matches | Full bundle | Mobile-only nav, responsive components |
client:only="react" |
Immediately, skip SSR entirely | Full bundle | Components that can't SSR (canvas, WebGL) |
| (none) | Never - static HTML only | Zero JS | Static content, cards, headers |
---
import NavBar from '../components/NavBar.tsx';
import CommentForm from '../components/CommentForm.tsx';
import ImageCarousel from '../components/ImageCarousel.svelte';
import MobileMenu from '../components/MobileMenu.vue';
import ThreeScene from '../components/ThreeScene.tsx';
---
<!-- Loads immediately - critical interactivity -->
<NavBar client:load />
<!-- Loads after page is idle - non-critical -->
<CommentForm client:idle />
<!-- Loads when scrolled into view - lazy -->
<ImageCarousel client:visible />
<!-- Loads only on mobile -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- Client-only, no SSR (WebGL can't run on server) -->
<ThreeScene client:only="react" />
Content Collections Quick Start
Define Schema
// src/content.config.ts (Astro 5) or src/content/config.ts (Astro 4)
import { defineCollection, z, reference } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string().max(160),
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: reference('authors'), // Reference another collection
}),
});
const authors = defineCollection({
loader: glob({ pattern: '**/*.json', base: './src/content/authors' }),
schema: z.object({
name: z.string(),
avatar: z.string(),
bio: z.string(),
socials: z.object({
twitter: z.string().optional(),
github: z.string().optional(),
}).optional(),
}),
});
export const collections = { blog, authors };
Query Collections
---
import { getCollection, getEntry } from 'astro:content';
// Get all non-draft blog posts, sorted by date
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
// Get a single entry
const post = await getEntry('blog', 'my-first-post');
// Resolve a reference
const author = await getEntry(post.data.author);
// Render content
const { Content, headings } = await post.render();
---
<Content />
Content Collections vs External CMS
| Criterion | Content Collections | External CMS (Payload, etc.) |
|---|---|---|
| Content type | Local markdown/MDX, docs, blogs | Relational data models |
| Authors | Developers (version-controlled) | Editors (admin UI, multi-user auth) |
| Validation | Type-safe via Zod at build time | CMS-side schemas + API contracts |
| Update cadence | Deploys with the site | Independent of deployments |
| API needs | None (build-time queries) | REST/GraphQL for other consumers |
| Workflow | Git PRs, simple review | Editorial workflows, drafts, roles |
Rule of thumb: start with Content Collections; reach for a CMS only when non-developers need to publish without a deploy, or when content is genuinely relational.
Project Structure Reference
project-root/
├── astro.config.mjs # Astro configuration
├── tsconfig.json # TypeScript config (extends astro/tsconfigs)
├── package.json
├── public/ # Static assets (copied as-is)
│ ├── favicon.svg
│ ├── robots.txt
│ └── og-image.png
├── src/
│ ├── pages/ # File-based routing
│ │ ├── index.astro # → /
│ │ ├── about.astro # → /about
│ │ ├── blog/
│ │ │ ├── index.astro # → /blog
│ │ │ └── [slug].astro # → /blog/:slug (dynamic)
│ │ ├── api/
│ │ │ └── search.ts # → /api/search (API endpoint)
│ │ └── [...slug].astro # → catch-all/404
│ ├── layouts/
│ │ ├── BaseLayout.astro # HTML shell, <head>, global styles
│ │ └── BlogPost.astro # Blog post layout
│ ├── components/
│ │ ├── Header.astro # Static Astro component
│ │ ├── Footer.astro
│ │ ├── NavBar.tsx # React island
│ │ └── Counter.svelte # Svelte island
│ ├── content/ # Content collections source files
│ │ ├── blog/
│ │ │ ├── post-one.md
│ │ │ └── post-two.mdx
│ │ └── authors/
│ │ └── jane.json
│ ├── content.config.ts # Collection schemas (Astro 5)
│ ├── middleware.ts # Request/response middleware
│ ├── styles/
│ │ └── global.css
│ └── lib/ # Shared utilities
│ ├── utils.ts
│ └── constants.ts
└── .env # Environment variables
View Transitions Quick Reference
---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
Transition Directives
<!-- Persist element across pages (keeps state, avoids re-render) -->
<audio transition:persist id="player">
<source src="/music.mp3" />
</audio>
<!-- Named transition for animation pairing -->
<img transition:name="hero" src={post.heroImage} />
<!-- Custom animation -->
<div transition:animate="slide">Content</div>
<div transition:animate="fade">Content</div>
<div transition:animate="none">No animation</div>
<!-- Persist with name (for multiple persistent elements) -->
<video transition:persist="media-player" />
Lifecycle Events
<script>
document.addEventListener('astro:before-preparation', (e) => {
// Before new page is fetched - cancel navigation, show loading
});
document.addEventListener('astro:after-preparation', (e) => {
// New page fetched, before swap
});
document.addEventListener('astro:before-swap', (e) => {
// Customize DOM swap behavior
});
document.addEventListener('astro:after-swap', () => {
// DOM updated - reinitialize scripts
});
document.addEventListener('astro:page-load', () => {
// Page fully loaded (fires on initial + every navigation)
// Use this instead of DOMContentLoaded with View Transitions
});
</script>
Back/Forward Handling
// astro.config.mjs
export default defineConfig({
prefetch: {
prefetchAll: true, // Prefetch all links on hover
defaultStrategy: 'hover', // 'hover' | 'tap' | 'viewport' | 'load'
},
});
<!-- Per-link prefetch control -->
<a href="/about" data-astro-prefetch>Prefetch on hover (default)</a>
<a href="/blog" data-astro-prefetch="viewport">Prefetch when visible</a>
<a href="/contact" data-astro-prefetch="load">Prefetch immediately</a>
<a href="/external" data-astro-prefetch="false">No prefetch</a>
Deployment Decision Tree
Where to deploy?
│
├─ Need edge computing + Cloudflare ecosystem (KV, D1, R2)?
│ └─ Cloudflare Pages/Workers
│ Adapter: @astrojs/cloudflare
│ Best for: Global edge, Workers bindings, cost-effective
│
├─ Need serverless + Vercel ecosystem (ISR, analytics)?
│ └─ Vercel
│ Adapter: @astrojs/vercel
│ Best for: Next.js migration, image optimization, ISR
│
├─ Need serverless + Netlify ecosystem (forms, identity)?
│ └─ Netlify
│ Adapter: @astrojs/netlify
│ Best for: JAMstack, built-in forms, split testing
│
├─ Need full server control (Docker, custom runtime)?
│ └─ Node.js (standalone or Express/Fastify)
│ Adapter: @astrojs/node
│ Best for: Self-hosted, WebSocket, long-running processes
│
└─ Pure static site (no SSR needed)?
└─ Any static host (GitHub Pages, S3, Cloudflare Pages)
No adapter needed, output: 'static'
Best for: Blogs, docs, marketing sites
Adapter Installation
# Cloudflare
npx astro add cloudflare
# Vercel
npx astro add vercel
# Netlify
npx astro add netlify
# Node.js
npx astro add node
Common Gotchas
| Gotcha | Why | Fix |
|---|---|---|
| Hydration mismatch errors | Server HTML differs from client render (dates, random IDs, browser APIs) | Use client:only for browser-dependent components, or ensure deterministic rendering |
import.meta.env undefined in client |
Only PUBLIC_ prefixed vars are exposed to client-side code |
Rename to PUBLIC_MY_VAR or pass via props from server |
| Dynamic routes 404 in SSG | getStaticPaths() not returning all possible params |
Ensure getStaticPaths() returns every valid path, or switch to hybrid/SSR |
| Images not optimizing | Using <img> instead of Astro's <Image /> component |
Import from astro:assets: import { Image } from 'astro:assets' and use local imports for src |
| SSR fails without adapter | output: 'server' or 'hybrid' requires a deployment adapter |
Install adapter: npx astro add cloudflare (or vercel, netlify, node) |
| MDX components not rendering | Custom components not passed to MDX content | Pass components via <Content components={{ MyComponent }} /> or use astro.config.mjs MDX config |
| Content collection schema changes not reflected | Type generation is cached, stale .astro types |
Run astro sync to regenerate types, restart dev server |
client:* on Astro components |
Client directives only work on framework components (React, Vue, Svelte) | Astro components are static-only; extract interactive parts to a framework component |
document / window is not defined |
Server-side code cannot access browser globals | Guard with if (typeof window !== 'undefined') or move to client:only |
| Styles leaking between components | Using global CSS instead of scoped styles | Use <style> (scoped by default in .astro) or <style is:global> intentionally |
| View Transitions break scripts | DOMContentLoaded only fires once with View Transitions |
Use astro:page-load event instead, which fires on every navigation |
| Env vars missing in production | .env not loaded or platform env vars not configured |
Use envField in astro.config.mjs for validation; set vars in platform dashboard |
Production Security Checklist
For every production deployment, address:
- CSP headers - configure a restrictive
Content-Security-Policy(see middleware patterns inreferences/deployment.md) - Remote image restrictions - enforce explicit
image.domains/remotePatternsallow-lists; never derive image URLs from user input (SSRF risk) - Host header validation - verify the request host matches expected domains in middleware (SSR/hybrid only)
- Secrets management - on Cloudflare, use Workers Bindings (
wrangler secret put), not env vars baked into code; elsewhere use platform secret stores - HTTPS only - ensure all external resources (scripts, images, fonts) load over HTTPS
- Input validation - sanitize all user input in SSR contexts (query params, form bodies, cookies)
Reference Files
| File | Contents | Lines |
|---|---|---|
references/content-collections.md |
Schema patterns, Zod types, querying, MDX, content layer API, migrations | ~500 |
references/islands-rendering.md |
Islands deep dive, client directives, framework integration, server islands | ~550 |
references/deployment.md |
Cloudflare/Vercel/Netlify/Node adapters, env vars, optimization | ~500 |
See Also
- typescript-ops - TypeScript patterns used throughout Astro projects
- tailwind-ops - Tailwind CSS integration with Astro (
@astrojs/tailwind) - javascript-ops - Core JS patterns for client-side island code
- container-orchestration - Docker patterns for self-hosted Astro (Node adapter)
- Astro Documentation
- Astro Integration Guide
Files (claude-mods)
-
assets
-
.gitkeep 0 B · in bundle
-
-
references
-
content-collections.md 18 KB
# Content Collections Reference Comprehensive guide to Astro content collections: schema definition, querying, references, MDX integration, and the Content Layer API. ## Schema Definition with Zod ### Basic Schema ```typescript // src/content.config.ts (Astro 5+) import { defineCollection, z } from 'astro:content'; import { glob, file } from 'astro/loaders'; const blog = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), schema: z.object({ title: z.string(), description: z.string(), pubDate: z.coerce.date(), draft: z.boolean().default(false), }), }); export const collections = { blog }; ``` ### All Supported Zod Types ```typescript import { defineCollection, z, reference } from 'astro:content'; import { glob, file } from 'astro/loaders'; const fullSchema = defineCollection({ loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }), schema: ({ image }) => z.object({ // String types title: z.string(), slug: z.string().optional(), description: z.string().max(160), canonical: z.string().url().optional(), // Number types readingTime: z.number().positive().optional(), order: z.number().int().min(0).default(0), // Date types pubDate: z.coerce.date(), // Accepts string or Date updatedDate: z.coerce.date().optional(), // Boolean draft: z.boolean().default(false), featured: z.boolean().default(false), // Enum category: z.enum(['tutorial', 'guide', 'reference', 'blog']), status: z.enum(['draft', 'review', 'published']).default('draft'), // Arrays tags: z.array(z.string()).default([]), relatedSlugs: z.array(z.string()).optional(), // Nested objects author: z.object({ name: z.string(), email: z.string().email().optional(), }), // Union types layout: z.union([ z.literal('default'), z.literal('wide'), z.literal('full'), ]).default('default'), // Image (validated by Astro, returns optimized metadata) heroImage: image().optional(), thumbnail: image().refine((img) => img.width >= 200, { message: 'Thumbnail must be at least 200px wide', }).optional(), // References to other collections author_ref: reference('authors'), relatedPosts: z.array(reference('blog')).default([]), // Custom transforms title_normalized: z.string().transform((val) => val.toLowerCase().trim()), // Passthrough for unknown fields // extra: z.record(z.unknown()), }), }); ``` ### Image Schema ```typescript // The image() helper validates that referenced images exist at build time const gallery = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/gallery' }), schema: ({ image }) => z.object({ title: z.string(), cover: image(), // Refine with dimension constraints hero: image().refine((img) => img.width >= 1080, { message: 'Hero image must be at least 1080px wide', }), // Array of images photos: z.array(image()).default([]), }), }); ``` Usage in frontmatter: ```markdown --- title: My Gallery cover: ./images/cover.jpg # Relative path to image hero: ../../assets/hero.png # Can reference shared assets photos: - ./images/photo1.jpg - ./images/photo2.jpg --- ``` ## References Between Collections ### Defining References ```typescript // src/content.config.ts import { defineCollection, z, reference } from 'astro:content'; import { glob, file } from 'astro/loaders'; const authors = defineCollection({ loader: glob({ pattern: '**/*.json', base: './src/content/authors' }), schema: z.object({ name: z.string(), avatar: z.string(), bio: z.string(), website: z.string().url().optional(), }), }); const categories = defineCollection({ loader: file('src/data/categories.json'), schema: z.object({ name: z.string(), slug: z.string(), description: z.string(), }), }); const blog = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), schema: z.object({ title: z.string(), // Single reference author: reference('authors'), // Optional reference reviewer: reference('authors').optional(), // Array of references categories: z.array(reference('categories')).default([]), // Self-reference (same collection) relatedPosts: z.array(reference('blog')).default([]), }), }); export const collections = { authors, categories, blog }; ``` ### Blog post frontmatter with references ```markdown --- title: Getting Started with Astro author: jane-doe reviewer: john-smith categories: - tutorials - astro relatedPosts: - advanced-astro-patterns - astro-vs-next --- ``` ### Resolving References ```astro --- import { getEntry, getCollection } from 'astro:content'; // Get the blog post const post = await getEntry('blog', 'getting-started'); // Resolve single reference const author = await getEntry(post.data.author); // author.data.name, author.data.avatar, etc. // Resolve optional reference const reviewer = post.data.reviewer ? await getEntry(post.data.reviewer) : null; // Resolve array of references const categories = await Promise.all( post.data.categories.map((ref) => getEntry(ref)) ); // Resolve self-references const relatedPosts = await Promise.all( post.data.relatedPosts.map((ref) => getEntry(ref)) ); --- <article> <h1>{post.data.title}</h1> <p>By {author.data.name}</p> {reviewer && <p>Reviewed by {reviewer.data.name}</p>} <div class="categories"> {categories.map((cat) => <span>{cat.data.name}</span>)} </div> </article> ``` ## Querying Collections ### getCollection ```typescript import { getCollection } from 'astro:content'; // Get all entries const allPosts = await getCollection('blog'); // Filter with callback (type-safe) const publishedPosts = await getCollection('blog', ({ data }) => { return !data.draft && data.pubDate <= new Date(); }); // Sort by date (descending) const sortedPosts = (await getCollection('blog')) .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); // Filter by tag const astroTagged = await getCollection('blog', ({ data }) => { return data.tags.includes('astro'); }); // Paginate const pageSize = 10; const page = 1; const paginatedPosts = sortedPosts.slice( (page - 1) * pageSize, page * pageSize ); ``` ### getEntry ```typescript import { getEntry } from 'astro:content'; // Get single entry by collection + id const post = await getEntry('blog', 'my-first-post'); // Returns null if not found (in Astro 5, throws if not found by default) if (!post) { return Astro.redirect('/404'); } // Access data console.log(post.data.title); // Type-safe frontmatter console.log(post.id); // Entry ID (filename without extension) // Render content const { Content, headings, remarkPluginFrontmatter } = await post.render(); ``` ### Dynamic Routes with Collections ```astro --- // src/pages/blog/[slug].astro import { getCollection, render } from 'astro:content'; export async function getStaticPaths() { const posts = await getCollection('blog', ({ data }) => !data.draft); return posts.map((post) => ({ params: { slug: post.id }, props: { post }, })); } const { post } = Astro.props; const { Content, headings } = await render(post); --- <article> <h1>{post.data.title}</h1> <time datetime={post.data.pubDate.toISOString()}> {post.data.pubDate.toLocaleDateString()} </time> <Content /> </article> ``` ### Pagination with Collections ```astro --- // src/pages/blog/[...page].astro import type { GetStaticPaths } from 'astro'; import { getCollection } from 'astro:content'; export const getStaticPaths = (async ({ paginate }) => { const posts = (await getCollection('blog', ({ data }) => !data.draft)) .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); return paginate(posts, { pageSize: 10 }); }) satisfies GetStaticPaths; const { page } = Astro.props; // page.data - current page entries // page.currentPage - current page number // page.lastPage - total pages // page.url.prev - previous page URL // page.url.next - next page URL // page.total - total entries --- {page.data.map((post) => ( <article> <a href={`/blog/${post.id}`}>{post.data.title}</a> </article> ))} <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> ``` ## MDX Integration ### Setup ```bash npx astro add mdx ``` ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; import mdx from '@astrojs/mdx'; export default defineConfig({ integrations: [mdx()], }); ``` ### Custom Components in MDX ```mdx --- title: Interactive Tutorial --- import Counter from '../../components/Counter.tsx'; import Callout from '../../components/Callout.astro'; import { Code } from 'astro:components'; # {frontmatter.title} Here's a live counter: <Counter client:visible initialCount={5} /> <Callout type="warning"> Remember to hydrate interactive components with a `client:*` directive! </Callout> ``` ### Passing Components to Rendered Content ```astro --- import { getEntry, render } from 'astro:content'; import Callout from '../components/Callout.astro'; import CodeBlock from '../components/CodeBlock.astro'; const post = await getEntry('blog', 'my-post'); const { Content } = await render(post); --- <!-- Override default HTML elements with custom components --> <Content components={{ h1: 'h2', <!-- Remap h1 to h2 --> blockquote: Callout, <!-- Replace blockquotes with Callout --> pre: CodeBlock, <!-- Replace code blocks --> }} /> ``` ### Remark and Rehype Plugins ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; import mdx from '@astrojs/mdx'; import remarkToc from 'remark-toc'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; export default defineConfig({ integrations: [mdx()], markdown: { remarkPlugins: [ remarkToc, remarkMath, ], rehypePlugins: [ rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'wrap' }], rehypeKatex, ], // Syntax highlighting shikiConfig: { theme: 'github-dark', wrap: true, }, }, }); ``` ## Content Layer API (Astro 5) The Content Layer API replaces the filesystem-coupled collection system with a flexible loader-based approach. ### Built-in Loaders ```typescript // src/content.config.ts import { defineCollection, z } from 'astro:content'; import { glob, file } from 'astro/loaders'; // Glob loader - load from filesystem with glob patterns const blog = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog', // Optional: generate ID from filename generateId: ({ entry, base, data }) => { return entry.replace(/\.mdx?$/, ''); }, }), schema: z.object({ title: z.string(), pubDate: z.coerce.date(), }), }); // File loader - load from a single JSON/YAML file const navigation = defineCollection({ loader: file('src/data/navigation.json'), schema: z.object({ label: z.string(), href: z.string(), order: z.number(), }), }); // File loader with nested data const settings = defineCollection({ loader: file('src/data/settings.yaml', { // Extract array from nested path parser: (text) => { const yaml = parseYaml(text); return yaml.site.menuItems; }, }), schema: z.object({ label: z.string(), url: z.string(), }), }); export const collections = { blog, navigation, settings }; ``` ### Custom Loaders ```typescript // src/loaders/api-loader.ts import type { Loader } from 'astro/loaders'; export function apiLoader(options: { url: string; apiKey: string }): Loader { return { name: 'api-loader', load: async ({ store, logger, parseData, generateDigest }) => { logger.info('Fetching data from API...'); const response = await fetch(options.url, { headers: { Authorization: `Bearer ${options.apiKey}` }, }); const items = await response.json(); // Clear previous data store.clear(); for (const item of items) { const digest = generateDigest(item); // Parse and validate data against schema const data = await parseData({ id: String(item.id), data: item, }); store.set({ id: String(item.id), data, digest, // Optional: rendered HTML content rendered: { html: item.content_html ?? '', }, }); } logger.info(`Loaded ${items.length} items`); }, }; } ``` ```typescript // src/content.config.ts import { defineCollection, z } from 'astro:content'; import { apiLoader } from '../loaders/api-loader'; const products = defineCollection({ loader: apiLoader({ url: 'https://api.example.com/products', apiKey: import.meta.env.API_KEY, }), schema: z.object({ name: z.string(), price: z.number(), description: z.string(), inStock: z.boolean(), }), }); export const collections = { products }; ``` ### CMS Integration Loaders ```typescript // Example: Notion loader (community package) import { notionLoader } from '@notionhq/astro-loader'; const docs = defineCollection({ loader: notionLoader({ databaseId: import.meta.env.NOTION_DB_ID, auth: import.meta.env.NOTION_API_KEY, }), schema: z.object({ title: z.string(), status: z.enum(['Draft', 'Published']), lastEdited: z.coerce.date(), }), }); ``` ### Incremental Builds ```typescript // Custom loader with incremental update support export function incrementalLoader(options: { url: string }): Loader { return { name: 'incremental-loader', load: async ({ store, logger, parseData, meta }) => { // meta.store persists between builds const lastSync = meta.get('lastSync'); const url = lastSync ? `${options.url}?since=${lastSync}` : options.url; const response = await fetch(url); const items = await response.json(); // Only update changed items (don't clear store) for (const item of items) { if (item.deleted) { store.delete(String(item.id)); } else { const data = await parseData({ id: String(item.id), data: item, }); store.set({ id: String(item.id), data }); } } meta.set('lastSync', new Date().toISOString()); }, }; } ``` ## Type Generation and InferEntrySchema ### Generating Types ```bash # Manually regenerate types after schema changes npx astro sync ``` ### Using InferEntrySchema ```typescript // src/lib/types.ts import type { InferEntrySchema, CollectionEntry } from 'astro:content'; // Infer the schema type for a collection type BlogPost = InferEntrySchema<'blog'>; // { title: string; description: string; pubDate: Date; ... } // Full collection entry type (includes id, data, render, etc.) type BlogEntry = CollectionEntry<'blog'>; // Use in utility functions function formatPost(post: CollectionEntry<'blog'>) { return { title: post.data.title, url: `/blog/${post.id}`, date: post.data.pubDate.toLocaleDateString(), }; } // Use in component props interface PostListProps { posts: CollectionEntry<'blog'>[]; showDrafts?: boolean; } ``` ### Type-safe Frontmatter in Layouts ```astro --- // src/layouts/BlogPost.astro import type { CollectionEntry } from 'astro:content'; interface Props { post: CollectionEntry<'blog'>; } const { post } = Astro.props; const { title, description, pubDate, heroImage, author } = post.data; --- <html> <head> <title>{title}</title> <meta name="description" content={description} /> </head> <body> <article> <h1>{title}</h1> <time datetime={pubDate.toISOString()}> {pubDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} </time> <slot /> </article> </body> </html> ``` ## Migration Guide: Astro 2/3/4 to Astro 5 ### Collection Config Location ``` # Astro 4 (legacy) src/content/config.ts # Astro 5 (Content Layer API) src/content.config.ts # Note: moved to src root ``` ### Adding Loaders (Required in Astro 5) ```typescript // Astro 4 - implicit filesystem loading const blog = defineCollection({ type: 'content', // Remove this schema: z.object({ ... }), }); // Astro 5 - explicit loaders import { glob, file } from 'astro/loaders'; const blog = defineCollection({ loader: glob({ // Add loader pattern: '**/*.{md,mdx}', base: './src/content/blog', }), schema: z.object({ ... }), }); ``` ### Data Collections Migration ```typescript // Astro 4 - data collections const authors = defineCollection({ type: 'data', // Remove this schema: z.object({ ... }), }); // Astro 5 - use file loader import { file } from 'astro/loaders'; const authors = defineCollection({ loader: file('src/data/authors.json'), // Or glob for multiple files schema: z.object({ ... }), }); ``` ### Entry ID Changes ```typescript // Astro 4 post.slug; // Used for routing post.id; // Included file extension: "my-post.md" // Astro 5 post.id; // Slug-like, no extension: "my-post" // post.slug removed - use post.id instead ``` ### Rendering Changes ```typescript // Astro 4 const { Content } = await post.render(); // Astro 5 import { render } from 'astro:content'; const { Content } = await render(post); ``` ### Checklist for Migration 1. Move `src/content/config.ts` to `src/content.config.ts` 2. Add `loader` property to every collection (use `glob()` or `file()`) 3. Remove `type: 'content'` and `type: 'data'` from collections 4. Replace `post.slug` with `post.id` in routing 5. Replace `post.render()` with `render(post)` from `astro:content` 6. Run `npx astro sync` to regenerate types 7. Update `getStaticPaths()` to use `post.id` instead of `post.slug` 8. Test all content pages and dynamic routes -
deployment.md 21.4 KB
# Deployment Reference Comprehensive guide to deploying Astro applications across platforms: Cloudflare, Vercel, Netlify, Node.js, and static hosting. ## Cloudflare Workers / Pages ### Setup ```bash npx astro add cloudflare ``` ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare'; export default defineConfig({ output: 'server', // or 'hybrid' adapter: cloudflare({ imageService: 'cloudflare', // Use Cloudflare Image Resizing platformProxy: { enabled: true, // Enable local bindings in dev }, }), site: 'https://example.com', }); ``` ### Wrangler Configuration ```toml # wrangler.toml name = "my-astro-site" compatibility_date = "2024-11-01" compatibility_flags = ["nodejs_compat"] pages_build_output_dir = "./dist" # KV Namespace binding [[kv_namespaces]] binding = "CACHE" id = "abc123" # D1 Database binding [[d1_databases]] binding = "DB" database_name = "my-db" database_id = "def456" # R2 Bucket binding [[r2_buckets]] binding = "ASSETS" bucket_name = "my-assets" # Environment variables [vars] API_URL = "https://api.example.com" # Secrets (set via wrangler secret put) # SECRET_KEY - set via `wrangler secret put SECRET_KEY` ``` ### Accessing Cloudflare Bindings ```typescript // Type definitions for Cloudflare bindings // src/env.d.ts /// <reference types="astro/client" /> type Runtime = import('@astrojs/cloudflare').Runtime<Env>; interface Env { CACHE: KVNamespace; DB: D1Database; ASSETS: R2Bucket; API_URL: string; SECRET_KEY: string; } declare namespace App { interface Locals extends Runtime {} } ``` ```astro --- // src/pages/api/data.ts import type { APIContext } from 'astro'; export async function GET({ locals }: APIContext) { const { env } = locals.runtime; // KV operations const cached = await env.CACHE.get('my-key'); if (cached) { return new Response(cached, { headers: { 'Content-Type': 'application/json' }, }); } // D1 database query const { results } = await env.DB .prepare('SELECT * FROM posts WHERE published = ?') .bind(true) .all(); // R2 object storage const object = await env.ASSETS.get('images/hero.jpg'); // Cache the result await env.CACHE.put('my-key', JSON.stringify(results), { expirationTtl: 3600, }); return new Response(JSON.stringify(results), { headers: { 'Content-Type': 'application/json' }, }); } ``` ### Cloudflare Middleware ```typescript // src/middleware.ts import { defineMiddleware } from 'astro:middleware'; export const onRequest = defineMiddleware(async ({ locals, request, cookies }, next) => { const { env } = locals.runtime; // Auth check using KV const session = cookies.get('session')?.value; if (session) { const user = await env.CACHE.get(`session:${session}`); if (user) { locals.user = JSON.parse(user); } } // Rate limiting with KV const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown'; const rateKey = `rate:${ip}`; const count = parseInt(await env.CACHE.get(rateKey) ?? '0'); if (count > 100) { return new Response('Rate limited', { status: 429 }); } await env.CACHE.put(rateKey, String(count + 1), { expirationTtl: 60 }); return next(); }); ``` ### Deployment ```bash # Build and deploy to Cloudflare Pages npm run build npx wrangler pages deploy dist # Or connect to Git for automatic deploys via Cloudflare Dashboard # Settings > Build > Framework preset: Astro ``` ## Vercel ### Setup ```bash npx astro add vercel ``` ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; import vercel from '@astrojs/vercel'; export default defineConfig({ output: 'server', // or 'hybrid' adapter: vercel({ imageService: true, // Use Vercel Image Optimization isr: { expiration: 60, // ISR: revalidate every 60 seconds }, webAnalytics: { enabled: true, // Enable Vercel Web Analytics }, maxDuration: 30, // Serverless function timeout (seconds) }), }); ``` ### Serverless vs Edge ```typescript // Default: serverless function export default defineConfig({ output: 'server', adapter: vercel(), }); // Edge function (faster cold start, limited APIs) export default defineConfig({ output: 'server', adapter: vercel({ edgeMiddleware: true, // Run middleware at the edge }), }); ``` ### ISR (Incremental Static Regeneration) ```astro --- // Per-page ISR configuration // src/pages/blog/[slug].astro export const prerender = false; // Set ISR headers Astro.response.headers.set( 'Cache-Control', 's-maxage=60, stale-while-revalidate=600' ); --- ``` ### Vercel Environment Variables ```bash # Set via Vercel CLI vercel env add PRIVATE_KEY vercel env add PUBLIC_API_URL # Or via vercel.json ``` ```json // vercel.json { "framework": "astro", "buildCommand": "astro build", "outputDirectory": "dist", "headers": [ { "source": "/api/(.*)", "headers": [ { "key": "Cache-Control", "value": "s-maxage=60" } ] } ], "redirects": [ { "source": "/old-path", "destination": "/new-path", "permanent": true } ] } ``` ### Deployment ```bash # Deploy to Vercel npx vercel # Production deploy npx vercel --prod # Or connect to Git for automatic deploys ``` ## Netlify ### Setup ```bash npx astro add netlify ``` ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; import netlify from '@astrojs/netlify'; export default defineConfig({ output: 'server', // or 'hybrid' adapter: netlify({ edgeMiddleware: true, // Run middleware at the edge imageCDN: true, // Use Netlify Image CDN }), }); ``` ### Netlify Configuration ```toml # netlify.toml [build] command = "astro build" publish = "dist" [build.environment] NODE_VERSION = "20" # Redirects [[redirects]] from = "/old-path" to = "/new-path" status = 301 # Custom headers [[headers]] for = "/api/*" [headers.values] Access-Control-Allow-Origin = "*" Cache-Control = "public, max-age=60" # Netlify Forms # Forms are auto-detected in static builds # For SSR, use Netlify Forms API ``` ### Netlify Edge Functions ```typescript // netlify/edge-functions/geolocation.ts import type { Context } from '@netlify/edge-functions'; export default async function (request: Request, context: Context) { const { country, city } = context.geo; // Add geo data to request headers for Astro middleware request.headers.set('x-country', country?.code ?? 'US'); request.headers.set('x-city', city ?? 'Unknown'); return context.next(); } export const config = { path: '/*' }; ``` ### Netlify Forms with Astro ```astro --- // Static output - Netlify auto-detects forms --- <form name="contact" method="POST" data-netlify="true"> <input type="hidden" name="form-name" value="contact" /> <input type="text" name="name" required /> <input type="email" name="email" required /> <textarea name="message" required></textarea> <button type="submit">Send</button> </form> ``` ### Deployment ```bash # Deploy to Netlify npx netlify deploy # Production deploy npx netlify deploy --prod # Or connect to Git for automatic deploys ``` ## Node.js (Self-hosted) ### Setup ```bash npx astro add node ``` ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone', // or 'middleware' }), }); ``` ### Standalone Mode ```bash # Build npm run build # Run (starts built-in HTTP server) HOST=0.0.0.0 PORT=4321 node dist/server/entry.mjs ``` ### Middleware Mode (Express/Fastify) ```typescript // astro.config.mjs import node from '@astrojs/node'; export default defineConfig({ output: 'server', adapter: node({ mode: 'middleware' }), }); ``` ```typescript // server.mjs - Custom Express server import express from 'express'; import { handler as astroHandler } from './dist/server/entry.mjs'; const app = express(); // Custom middleware before Astro app.use('/health', (req, res) => { res.json({ status: 'ok' }); }); // Serve static files app.use(express.static('dist/client')); // Astro handles everything else app.use(astroHandler); const port = process.env.PORT || 4321; app.listen(port, () => { console.log(`Server running on port ${port}`); }); ``` ```typescript // server-fastify.mjs - Custom Fastify server import Fastify from 'fastify'; import fastifyStatic from '@fastify/static'; import { handler as astroHandler } from './dist/server/entry.mjs'; import { fileURLToPath } from 'url'; import path from 'path'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = Fastify({ logger: true }); // Static files app.register(fastifyStatic, { root: path.join(__dirname, 'dist/client'), }); // Health check app.get('/health', async () => ({ status: 'ok' })); // Astro handler app.use(astroHandler); app.listen({ port: 4321, host: '0.0.0.0' }); ``` ### Docker Deployment ```dockerfile # Dockerfile FROM node:20-slim AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-slim AS runtime WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./ ENV HOST=0.0.0.0 ENV PORT=4321 EXPOSE 4321 HEALTHCHECK --interval=30s --timeout=3s \ CMD curl -f http://localhost:4321/health || exit 1 CMD ["node", "dist/server/entry.mjs"] ``` ```yaml # docker-compose.yml services: astro: build: . ports: - "4321:4321" environment: - DATABASE_URL=postgres://db:5432/app - SECRET_KEY=${SECRET_KEY} restart: unless-stopped depends_on: - db db: image: postgres:16-alpine volumes: - pgdata:/var/lib/postgresql/data environment: POSTGRES_DB: app POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: pgdata: ``` ## Static Hosting ### Configuration ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'static', // Default - all pages prerendered site: 'https://example.com', base: '/my-app', // If hosted at a subpath }); ``` ### GitHub Pages ```yaml # .github/workflows/deploy.yml name: Deploy to GitHub Pages on: push: branches: [main] permissions: contents: read pages: write id-token: write jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run build - uses: actions/upload-pages-artifact@v3 with: path: dist deploy: needs: build runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment uses: actions/deploy-pages@v4 ``` ```typescript // astro.config.mjs for GitHub Pages export default defineConfig({ site: 'https://username.github.io', base: '/repo-name', // For project pages (not needed for user pages) }); ``` ### S3 + CloudFront ```bash # Build and sync to S3 npm run build aws s3 sync dist s3://my-bucket --delete aws cloudfront create-invalidation --distribution-id DIST_ID --paths "/*" ``` ## Environment Variables ### Astro Environment Variable Rules ``` # .env # Private (server-only) - NOT available in client-side code DATABASE_URL=postgres://localhost:5432/mydb API_SECRET=sk-12345 SESSION_KEY=abc # Public (available in client-side code) - MUST start with PUBLIC_ PUBLIC_API_URL=https://api.example.com PUBLIC_SITE_NAME=My Site PUBLIC_GA_ID=G-12345 ``` ### Accessing Environment Variables ```typescript // Server-side (pages, middleware, API routes, server islands) const dbUrl = import.meta.env.DATABASE_URL; // Works const apiKey = import.meta.env.API_SECRET; // Works const publicUrl = import.meta.env.PUBLIC_API_URL; // Works // Client-side (browser, client:* components) const publicUrl = import.meta.env.PUBLIC_API_URL; // Works const dbUrl = import.meta.env.DATABASE_URL; // undefined! ``` ### envField Schema Validation (Astro 5) ```typescript // astro.config.mjs import { defineConfig, envField } from 'astro/config'; export default defineConfig({ env: { schema: { // Server-only variables DATABASE_URL: envField.string({ context: 'server', access: 'secret', optional: false, }), API_KEY: envField.string({ context: 'server', access: 'secret', }), PORT: envField.number({ context: 'server', access: 'public', default: 4321, }), // Client-accessible variables PUBLIC_API_URL: envField.string({ context: 'client', access: 'public', }), PUBLIC_FEATURE_FLAG: envField.boolean({ context: 'client', access: 'public', default: false, }), }, }, }); ``` ```typescript // Type-safe env access with validation import { DATABASE_URL, PORT } from 'astro:env/server'; import { PUBLIC_API_URL, PUBLIC_FEATURE_FLAG } from 'astro:env/client'; // These are typed and validated at build time console.log(DATABASE_URL); // string (required) console.log(PORT); // number (defaults to 4321) console.log(PUBLIC_API_URL); // string (required) ``` ### Platform-specific Environment Variables ```bash # Cloudflare - set in wrangler.toml or dashboard wrangler secret put API_KEY # Vercel vercel env add API_KEY production # Netlify netlify env:set API_KEY "value" # Docker docker run -e DATABASE_URL=... my-astro-app ``` ## Headers and Redirects ### Middleware-based Headers ```typescript // src/middleware.ts import { defineMiddleware, sequence } from 'astro:middleware'; const securityHeaders = defineMiddleware(async (context, next) => { const response = await next(); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-XSS-Protection', '1; mode=block'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); response.headers.set( 'Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" ); return response; }); const cacheHeaders = defineMiddleware(async (context, next) => { const response = await next(); // Cache static assets aggressively if (context.url.pathname.startsWith('/_astro/')) { response.headers.set('Cache-Control', 'public, max-age=31536000, immutable'); } return response; }); export const onRequest = sequence(securityHeaders, cacheHeaders); ``` ### Static File Headers ``` # public/_headers (Cloudflare Pages / Netlify) /* X-Content-Type-Options: nosniff X-Frame-Options: DENY /_astro/* Cache-Control: public, max-age=31536000, immutable /api/* Cache-Control: no-cache Access-Control-Allow-Origin: * ``` ### Redirects ``` # public/_redirects (Cloudflare Pages / Netlify) /old-blog/* /blog/:splat 301 /legacy / 302 /docs https://docs.example.com 301 ``` ```typescript // Programmatic redirects in middleware import { defineMiddleware } from 'astro:middleware'; const redirects: Record<string, { to: string; status: 301 | 302 }> = { '/old-path': { to: '/new-path', status: 301 }, '/legacy': { to: '/', status: 302 }, }; export const onRequest = defineMiddleware(async ({ url, redirect }, next) => { const rule = redirects[url.pathname]; if (rule) { return redirect(rule.to, rule.status); } return next(); }); ``` ## SSR Streaming ### Response Streaming ```astro --- // Astro streams HTML by default in SSR mode // Components render top-to-bottom, streaming chunks to the client // Slow data fetch - page header already visible while this loads const slowData = await fetch('https://slow-api.example.com/data') .then(r => r.json()); --- <html> <body> <!-- This streams immediately --> <h1>Page Title</h1> <!-- This streams after slowData resolves --> <div>{slowData.content}</div> </body> </html> ``` ### Streaming with Server Islands ```astro --- // Combine streaming with server islands for optimal loading import SlowWidget from '../components/SlowWidget.astro'; import UserDashboard from '../components/UserDashboard.astro'; --- <!-- Streams immediately --> <header>Fast static content</header> <!-- Server island: page sends without waiting for this --> <SlowWidget server:defer> <div slot="fallback">Loading widget...</div> </SlowWidget> <!-- This also streams immediately (doesn't wait for SlowWidget) --> <main>More fast content</main> <UserDashboard server:defer> <div slot="fallback" class="skeleton">Loading dashboard...</div> </UserDashboard> ``` ## Build Optimization ### Bundle Analysis ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; export default defineConfig({ vite: { build: { // Analyze bundle rollupOptions: { output: { manualChunks: { // Group vendor chunks 'react-vendor': ['react', 'react-dom'], 'utils': ['date-fns', 'lodash-es'], }, }, }, }, }, }); ``` ### Prefetch Strategies ```typescript // astro.config.mjs export default defineConfig({ prefetch: { // Prefetch links on hover (default for View Transitions) defaultStrategy: 'hover', // Or be more aggressive // defaultStrategy: 'viewport', // Prefetch when link enters viewport // defaultStrategy: 'load', // Prefetch all links on page load // Prefetch all same-origin links prefetchAll: false, }, }); ``` ```astro <!-- Per-link prefetch control --> <a href="/about" data-astro-prefetch="hover">Hover to prefetch</a> <a href="/important" data-astro-prefetch="viewport">Prefetch when visible</a> <a href="/critical" data-astro-prefetch="load">Prefetch immediately</a> <a href="/external" data-astro-prefetch="false">Never prefetch</a> ``` ### Image Service Configuration ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; export default defineConfig({ image: { // Use Sharp (default, best quality) service: { entrypoint: 'astro/assets/services/sharp' }, // Remote image domains domains: ['cdn.example.com', 'images.unsplash.com'], // Remote patterns (more granular) remotePatterns: [ { protocol: 'https', hostname: '**.example.com', pathname: '/images/**', }, ], }, }); ``` ```astro --- import { Image, Picture } from 'astro:assets'; import heroImage from '../assets/hero.jpg'; --- <!-- Optimized image with automatic format conversion --> <Image src={heroImage} alt="Hero image" width={1200} height={600} quality={80} format="avif" loading="eager" <!-- Above fold: eager, below fold: lazy (default) --> /> <!-- Responsive picture with multiple formats --> <Picture src={heroImage} formats={['avif', 'webp']} widths={[400, 800, 1200]} sizes="(max-width: 768px) 100vw, 1200px" alt="Responsive hero" /> <!-- Remote image (must be in domains/remotePatterns) --> <Image src="https://cdn.example.com/photo.jpg" alt="Remote image" width={800} height={400} inferSize <!-- Auto-detect dimensions --> /> ``` ### Compression and Performance ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; import compress from 'astro-compress'; export default defineConfig({ integrations: [ compress({ CSS: true, HTML: true, Image: true, JavaScript: true, SVG: true, }), ], compressHTML: true, // Built-in HTML minification build: { inlineStylesheets: 'auto', // Inline small CSS (<4KB) }, vite: { build: { cssMinify: 'lightningcss', // Faster CSS minification }, }, }); ``` ### Sitemap and SEO ```bash npx astro add sitemap ``` ```typescript // astro.config.mjs import sitemap from '@astrojs/sitemap'; export default defineConfig({ site: 'https://example.com', integrations: [ sitemap({ filter: (page) => !page.includes('/admin'), changefreq: 'weekly', priority: 0.7, lastmod: new Date(), i18n: { defaultLocale: 'en', locales: { en: 'en-US', es: 'es-ES', }, }, }), ], }); ``` ```astro --- // src/layouts/BaseLayout.astro - SEO head tags interface Props { title: string; description: string; image?: string; canonicalURL?: string; } const { title, description, image = '/og-default.png', canonicalURL = Astro.url.href, } = Astro.props; --- <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{title}</title> <meta name="description" content={description} /> <link rel="canonical" href={canonicalURL} /> <!-- Open Graph --> <meta property="og:title" content={title} /> <meta property="og:description" content={description} /> <meta property="og:image" content={new URL(image, Astro.site)} /> <meta property="og:url" content={canonicalURL} /> <meta property="og:type" content="website" /> <!-- Twitter --> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:title" content={title} /> <meta name="twitter:description" content={description} /> <meta name="twitter:image" content={new URL(image, Astro.site)} /> <!-- Sitemap --> <link rel="sitemap" href="/sitemap-index.xml" /> </head> <body> <slot /> </body> </html> ``` -
islands-rendering.md 20.6 KB
# Islands Architecture and Rendering Reference Deep dive into Astro's islands architecture, partial hydration, client directives, framework integration, and server islands. ## How Islands Architecture Works Astro renders all components to static HTML on the server by default. Interactive components ("islands") are selectively hydrated on the client, shipping JavaScript only for the parts of the page that need interactivity. ``` Traditional SPA: ┌──────────────────────────────────────┐ │ Full JavaScript App │ ← All JS shipped │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ │Nav │ │Hero│ │Card│ │Form│ │ │ └────┘ └────┘ └────┘ └────┘ │ └──────────────────────────────────────┘ Astro Islands: ┌──────────────────────────────────────┐ │ Static HTML (zero JS) │ │ ┌────┐ │ │ │Nav │ ← Island (client:load) │ ← Only island JS shipped │ └────┘ │ │ ┌────────────┐ │ │ │ Hero Text │ ← Static HTML │ ← No JS │ └────────────┘ │ │ ┌────┐ ┌────┐ ┌────┐ │ │ │Card│ │Card│ │Card│ ← Static │ ← No JS │ └────┘ └────┘ └────┘ │ │ ┌──────┐ │ │ │ Form │ ← Island (client:visible) │ ← JS loaded on scroll │ └──────┘ │ └──────────────────────────────────────┘ ``` ### The Hydration Process 1. **Server**: Astro renders ALL components (including React, Vue, Svelte) to HTML 2. **Client**: Browser receives pure HTML - instant display, zero JS 3. **Hydration**: Based on client directive, island JS is loaded and components become interactive 4. **Result**: Page is visible immediately; interactivity loads progressively ```astro --- // This component renders to HTML on the server // Then hydrates with React on the client import SearchBar from '../components/SearchBar.tsx'; --- <!-- Static HTML (no JS) --> <header> <h1>My Site</h1> <!-- React island - hydrates when visible --> <SearchBar client:visible placeholder="Search docs..." /> </header> ``` ## Client Directives Deep Dive ### client:load Hydrates immediately when the page loads. Highest priority. ```astro --- import AuthButton from '../components/AuthButton.tsx'; import NavMenu from '../components/NavMenu.tsx'; --- <!-- Use for: above-fold interactive elements that users interact with immediately --> <AuthButton client:load /> <NavMenu client:load /> ``` **When to use:** - Navigation menus that must be interactive on page load - Authentication state indicators - Critical CTAs above the fold - Elements that must respond to first user interaction **When NOT to use:** - Content below the fold (use `client:visible`) - Non-critical widgets (use `client:idle`) - Large components that aren't immediately needed ### client:idle Hydrates after the page has finished loading and `requestIdleCallback` fires. ```astro --- import CommentSection from '../components/CommentSection.tsx'; import NewsletterSignup from '../components/NewsletterSignup.vue'; import ShareButtons from '../components/ShareButtons.tsx'; --- <!-- Use for: important but not immediately critical interactivity --> <CommentSection client:idle postId={post.id} /> <NewsletterSignup client:idle /> <ShareButtons client:idle url={Astro.url} /> ``` **When to use:** - Comment sections - Newsletter signup forms - Social share buttons - Chat widgets - Analytics dashboards below hero **Behavior:** - Waits for `requestIdleCallback` (or `setTimeout` fallback after 200ms) - Doesn't block initial page render or first paint - Loads before user scrolls (unlike `client:visible`) ### client:visible Hydrates when the element enters the viewport (IntersectionObserver). ```astro --- import ImageCarousel from '../components/ImageCarousel.svelte'; import InteractiveChart from '../components/InteractiveChart.tsx'; import Testimonials from '../components/Testimonials.vue'; --- <!-- Use for: below-fold content that's only needed when scrolled to --> <ImageCarousel client:visible images={gallery} /> <InteractiveChart client:visible data={chartData} /> <Testimonials client:visible /> <!-- With rootMargin - preload 200px before visible --> <InteractiveChart client:visible={{rootMargin: "200px"}} data={chartData} /> ``` **When to use:** - Image carousels/galleries far down the page - Interactive charts and data visualizations - Testimonial sliders - Footer widgets - Any interactive content below the fold **Behavior:** - Uses IntersectionObserver to detect visibility - Zero JS loaded until element is about to enter viewport - Supports `rootMargin` option for preloading ### client:media Hydrates only when a CSS media query matches. ```astro --- import MobileMenu from '../components/MobileMenu.tsx'; import DesktopSidebar from '../components/DesktopSidebar.tsx'; import DarkModeToggle from '../components/DarkModeToggle.tsx'; --- <!-- Only hydrate on mobile --> <MobileMenu client:media="(max-width: 768px)" /> <!-- Only hydrate on desktop --> <DesktopSidebar client:media="(min-width: 1024px)" /> <!-- Hydrate based on user preference --> <DarkModeToggle client:media="(prefers-color-scheme: dark)" /> ``` **When to use:** - Mobile-only navigation (hamburger menus) - Desktop-only sidebars with interactivity - Responsive components that differ dramatically by viewport - Reduced motion alternatives **Behavior:** - Checks media query on load; hydrates if matched - Also watches for changes (e.g., viewport resize triggers hydration) - If media query never matches, JS is never loaded ### client:only Skips server rendering entirely. Component renders ONLY on the client. ```astro --- import ThreeScene from '../components/ThreeScene.tsx'; import MapComponent from '../components/Map.tsx'; import CanvasEditor from '../components/CanvasEditor.svelte'; --- <!-- MUST specify the framework --> <ThreeScene client:only="react" /> <MapComponent client:only="react" /> <CanvasEditor client:only="svelte" /> <!-- Valid framework values: --> <!-- client:only="react" --> <!-- client:only="preact" --> <!-- client:only="vue" --> <!-- client:only="svelte" --> <!-- client:only="solid-js" --> <!-- client:only="lit" --> ``` **When to use:** - WebGL / Three.js / Canvas components - Map libraries (Leaflet, Mapbox) that access `window` - Browser-only APIs (Web Audio, WebRTC, etc.) - Components that crash during SSR **Behavior:** - No HTML rendered on server (shows nothing until JS loads) - No hydration mismatch possible (no server HTML to diff) - Framework string is required so Astro knows which renderer to use ### No Directive (Static) Component renders to HTML only. Zero client-side JavaScript. ```astro --- import Card from '../components/Card.astro'; import Footer from '../components/Footer.astro'; import BlogPostPreview from '../components/BlogPostPreview.astro'; --- <!-- Pure HTML, no JS ever --> <Card title="Hello" description="World" /> <Footer /> <BlogPostPreview post={post} /> ``` **When to use:** - Content display (cards, headers, footers) - Anything that doesn't need user interaction - Layout components - Most of your page (aim for 80%+ static) ## Framework Integration ### Multi-framework Setup ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; import vue from '@astrojs/vue'; import svelte from '@astrojs/svelte'; import solid from '@astrojs/solid-js'; import preact from '@astrojs/preact'; export default defineConfig({ integrations: [ react({ include: ['**/react/**'], // Only process files in react/ dirs }), preact({ include: ['**/preact/**'], // Disambiguate from React }), vue(), svelte(), solid({ include: ['**/solid/**'], }), ], }); ``` ### File Organization for Multi-framework ``` src/components/ ├── react/ # React components (.tsx/.jsx) │ ├── Counter.tsx │ └── SearchBar.tsx ├── vue/ # Vue components (.vue) │ ├── TodoList.vue │ └── Modal.vue ├── svelte/ # Svelte components (.svelte) │ ├── Carousel.svelte │ └── Toggle.svelte ├── solid/ # Solid components (.tsx) │ └── DataGrid.tsx ├── Header.astro # Astro (static) └── Footer.astro ``` ### Using Multiple Frameworks in One Page ```astro --- import ReactNav from '../components/react/NavBar.tsx'; import VueForm from '../components/vue/ContactForm.vue'; import SvelteCarousel from '../components/svelte/Carousel.svelte'; import Footer from '../components/Footer.astro'; --- <ReactNav client:load user={user} /> <main> <h1>Multi-framework Page</h1> <SvelteCarousel client:visible items={images} /> <VueForm client:idle endpoint="/api/contact" /> </main> <Footer /> ``` ## Sharing State Between Islands Islands are isolated by default. Here are patterns for sharing state. ### Nanostores (Recommended) ```bash npm install nanostores @nanostores/react @nanostores/vue @nanostores/svelte ``` ```typescript // src/stores/cart.ts import { atom, map, computed } from 'nanostores'; // Simple atom export const isMenuOpen = atom(false); // Map (object store) export interface CartItem { id: string; name: string; price: number; quantity: number; } export const cartItems = map<Record<string, CartItem>>({}); // Computed (derived state) export const cartTotal = computed(cartItems, (items) => { return Object.values(items).reduce( (sum, item) => sum + item.price * item.quantity, 0 ); }); // Actions export function addToCart(item: CartItem) { const existing = cartItems.get()[item.id]; if (existing) { cartItems.setKey(item.id, { ...existing, quantity: existing.quantity + 1, }); } else { cartItems.setKey(item.id, { ...item, quantity: 1 }); } } export function removeFromCart(id: string) { const items = { ...cartItems.get() }; delete items[id]; cartItems.set(items); } ``` ```tsx // React component using the store import { useStore } from '@nanostores/react'; import { cartItems, cartTotal, addToCart } from '../stores/cart'; export function CartButton() { const $items = useStore(cartItems); const $total = useStore(cartTotal); const count = Object.keys($items).length; return ( <button> Cart ({count}) - ${$total.toFixed(2)} </button> ); } ``` ```svelte <!-- Svelte component using same store --> <script> import { cartItems, cartTotal } from '../stores/cart'; </script> <div> {#each Object.values($cartItems) as item} <p>{item.name}: ${item.price} x {item.quantity}</p> {/each} <p>Total: ${$cartTotal.toFixed(2)}</p> </div> ``` ### Custom Events ```astro --- // For simple one-way communication between islands --- <script> // Dispatch from any island document.dispatchEvent(new CustomEvent('cart:add', { detail: { id: '123', name: 'Widget', price: 9.99 } })); // Listen in any island document.addEventListener('cart:add', (e: CustomEvent) => { console.log('Added to cart:', e.detail); }); </script> ``` ### URL State ```typescript // Use URL search params for shareable state function updateFilter(key: string, value: string) { const url = new URL(window.location.href); url.searchParams.set(key, value); window.history.pushState({}, '', url); // Notify other islands document.dispatchEvent(new CustomEvent('url:change', { detail: Object.fromEntries(url.searchParams), })); } ``` ## Performance Budgets ### Measuring Island Size ```typescript // astro.config.mjs - analyze bundle import { defineConfig } from 'astro/config'; import { visualizer } from 'rollup-plugin-visualizer'; export default defineConfig({ vite: { plugins: [ visualizer({ filename: './dist/stats.html', gzipSize: true, brotliSize: true, }), ], }, }); ``` ### Bundle Size Guidelines | Component Type | Target Size (gzipped) | Strategy | |---------------|----------------------|----------| | Critical island (client:load) | < 20 KB | Minimize dependencies | | Deferred island (client:idle) | < 50 KB | Acceptable, loads after paint | | Lazy island (client:visible) | < 100 KB | OK for rich interactive content | | Full SPA island (client:only) | < 200 KB | Consider code splitting | ### Optimization Strategies ```typescript // 1. Prefer Preact over React for smaller islands import preact from '@astrojs/preact'; // 2. Dynamic imports for heavy dependencies const Chart = lazy(() => import('./HeavyChart')); // 3. Tree-shakeable imports import { format } from 'date-fns/format'; // Good: specific import // import { format } from 'date-fns'; // Bad: imports everything // 4. Use Astro's built-in Image optimization import { Image } from 'astro:assets'; ``` ## Server Islands (Astro 5) Server islands allow you to defer rendering of specific components to after the initial page response, enabling personalized content within cached pages. ### How Server Islands Work ``` Request Flow: 1. Edge/CDN serves cached static HTML instantly 2. Page displays with fallback content for server islands 3. Server islands fetch their content via separate requests 4. Dynamic content streams in and replaces fallbacks ┌─────────────────────────────────┐ │ Cached Static Page (CDN) │ │ │ │ ┌──────────────────────┐ │ │ │ Static Header │ │ ← Cached │ └──────────────────────┘ │ │ ┌──────────────────────┐ │ │ │ server:defer │ │ ← Fetched separately │ │ (user-specific data) │ │ after page load │ └──────────────────────┘ │ │ ┌──────────────────────┐ │ │ │ Static Content │ │ ← Cached │ └──────────────────────┘ │ └─────────────────────────────────┘ ``` ### Basic Usage ```astro --- // src/components/UserGreeting.astro const user = await getUser(Astro.cookies.get('session')); --- <div> <p>Welcome back, {user.name}!</p> <p>You have {user.notifications} new notifications.</p> </div> ``` ```astro --- // src/pages/index.astro import UserGreeting from '../components/UserGreeting.astro'; import ProductRecommendations from '../components/ProductRecommendations.astro'; --- <html> <body> <h1>Welcome to our Store</h1> <!-- This component renders on the server AFTER the page is sent --> <UserGreeting server:defer> <!-- Fallback shown while server island loads --> <p slot="fallback">Loading your profile...</p> </UserGreeting> <!-- Another server island --> <ProductRecommendations server:defer> <div slot="fallback" class="skeleton-grid"> <!-- Skeleton placeholder --> </div> </ProductRecommendations> <!-- This is static, served from cache --> <footer>Static footer content</footer> </body> </html> ``` ### Server Islands with Props ```astro --- // Server islands can receive serializable props import PricingTable from '../components/PricingTable.astro'; --- <!-- Props are encrypted and sent with the deferred request --> <PricingTable server:defer productId="abc123" region={Astro.locals.region} > <p slot="fallback">Loading pricing...</p> </PricingTable> ``` ### Caching Strategy with Server Islands ```typescript // astro.config.mjs export default defineConfig({ output: 'server', adapter: cloudflare(), }); ``` ```astro --- // src/pages/product/[id].astro // The page itself can be cached aggressively Astro.response.headers.set('Cache-Control', 'public, max-age=3600'); import ProductDetails from '../components/ProductDetails.astro'; import UserReviews from '../components/UserReviews.astro'; import AddToCart from '../components/AddToCart.astro'; --- <!-- Static product info (cached) --> <ProductDetails productId={Astro.params.id} /> <!-- Dynamic, personalized (server island) --> <AddToCart server:defer productId={Astro.params.id}> <button slot="fallback" disabled>Loading...</button> </AddToCart> <!-- Dynamic, frequently updated (server island) --> <UserReviews server:defer productId={Astro.params.id}> <p slot="fallback">Loading reviews...</p> </UserReviews> ``` ## Slot Patterns ### Passing Astro Content into Framework Islands ```astro --- import ReactAccordion from '../components/react/Accordion.tsx'; --- <!-- Astro content becomes children in React --> <ReactAccordion client:visible title="FAQ"> <p>This HTML is passed as children to the React component.</p> <ul> <li>Static content rendered by Astro</li> <li>Hydrated by React when visible</li> </ul> </ReactAccordion> ``` ```tsx // React component receiving Astro slot content interface AccordionProps { title: string; children: React.ReactNode; // Astro slot content arrives as children } export function Accordion({ title, children }: AccordionProps) { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(!isOpen)}>{title}</button> {isOpen && <div>{children}</div>} </div> ); } ``` ### Named Slots with Framework Components ```astro --- import ReactCard from '../components/react/Card.tsx'; --- <!-- Named slots map to props in React --> <ReactCard client:idle> <h2 slot="header">Card Title</h2> <p>Default slot content (becomes children)</p> <span slot="footer">Card footer</span> </ReactCard> ``` ```tsx // React component with named slots interface CardProps { header?: React.ReactNode; footer?: React.ReactNode; children: React.ReactNode; } export function Card({ header, footer, children }: CardProps) { return ( <div className="card"> {header && <div className="card-header">{header}</div>} <div className="card-body">{children}</div> {footer && <div className="card-footer">{footer}</div>} </div> ); } ``` ### Nested Islands ```astro --- import ReactWrapper from '../components/react/Wrapper.tsx'; import SvelteWidget from '../components/svelte/Widget.svelte'; --- <!-- Nested islands hydrate independently --> <ReactWrapper client:load> <!-- This Svelte component hydrates separately from the React wrapper --> <SvelteWidget client:visible count={5} /> </ReactWrapper> ``` **Important:** Nested islands are NOT nested in the JavaScript sense. Each island hydrates independently. The React wrapper doesn't "own" the Svelte widget - they just happen to be visually nested in the HTML. ## Advanced Patterns ### Conditional Hydration ```astro --- import HeavyEditor from '../components/react/Editor.tsx'; const isEditor = Astro.url.searchParams.has('edit'); --- <!-- Only include the island if editing --> {isEditor ? ( <HeavyEditor client:load content={content} /> ) : ( <div class="content" set:html={renderedContent} /> )} ``` ### Island with Loading State ```tsx // React island with built-in loading state import { useState, useEffect } from 'react'; export function DataWidget({ endpoint }: { endpoint: string }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetch(endpoint) .then((r) => r.json()) .then((d) => { setData(d); setLoading(false); }); }, [endpoint]); if (loading) return <div class="skeleton" />; return <div>{/* render data */}</div>; } ``` ### Transition-aware Islands ```tsx // React island that reinitializes on View Transition navigation import { useEffect } from 'react'; export function PageTracker() { useEffect(() => { // This runs on initial hydration AND after View Transition navigations const handler = () => { console.log('Page changed:', window.location.pathname); }; document.addEventListener('astro:page-load', handler); return () => document.removeEventListener('astro:page-load', handler); }, []); return null; } ```
-
-
scripts
-
.gitkeep 0 B · in bundle
-
-
SKILL.md 15.6 KB
--- name: astro-ops description: "Astro framework patterns, islands architecture, content collections, rendering strategies, and deployment. Use for: astro, islands architecture, content collections, astro cloudflare, view transitions, partial hydration, astrojs, SSG, SSR, hybrid rendering, astro adapter." when_to_use: "Use when building or deploying an Astro site — e.g. 'which rendering mode: SSG, SSR, or hybrid', 'add a React island with client:load', 'set up content collections', 'deploy Astro to Cloudflare/Vercel'. Covers islands architecture, view transitions, and adapters; not general React/Next.js work." license: MIT allowed-tools: "Read Write Bash" metadata: author: claude-mods related-skills: typescript-ops, tailwind-ops, javascript-ops --- # Astro Operations > Facts verified as of 2026-07. Comprehensive patterns for Astro framework development: islands architecture, content collections, rendering strategies, view transitions, and multi-platform deployment. ## Rendering Strategy Decision Tree ``` Which rendering strategy? │ ├─ Is content mostly static (blog, docs, marketing)? │ ├─ YES → Does it change less than daily? │ │ ├─ YES → SSG (output: 'static') │ │ │ Fastest TTFB, CDN-cacheable, zero runtime cost │ │ └─ NO → Hybrid (output: 'hybrid') │ │ Default static + opt-in SSR per route │ └─ NO → Does every page need personalization? │ ├─ YES → SSR (output: 'server') │ │ Dynamic per-request, auth-aware, real-time data │ └─ NO → Hybrid (output: 'hybrid') │ Static shell + server islands for dynamic parts │ ├─ Does the app need real-time interactivity (dashboard, SPA)? │ ├─ YES → Is it a full SPA with client-side routing? │ │ ├─ YES → Consider React/Vue SPA instead, or Astro + client:only │ │ └─ NO → Hybrid + islands architecture │ │ Interactive islands in static pages │ └─ NO → SSG (output: 'static') │ ├─ Build time concerns (>10k pages)? │ ├─ YES → Hybrid with on-demand rendering │ │ Prerender popular pages, SSR the long tail │ └─ NO → SSG handles it fine │ └─ Need edge computing (low latency globally)? ├─ YES → SSR + Cloudflare/Vercel Edge adapter └─ NO → SSR + Node adapter or SSG ``` ### Configuration ```typescript // astro.config.mjs import { defineConfig } from 'astro/config'; // SSG (default) - all pages prerendered at build time export default defineConfig({ output: 'static', }); // SSR - all pages rendered on request export default defineConfig({ output: 'server', adapter: cloudflare(), // or vercel(), netlify(), node() }); // Hybrid - static default, opt-in SSR per page export default defineConfig({ output: 'hybrid', adapter: cloudflare(), }); ``` ```astro --- // In hybrid mode, opt OUT of prerendering for specific pages: export const prerender = false; // In SSR mode, opt IN to prerendering: export const prerender = true; --- ``` ## Islands Architecture Quick Reference | Directive | Hydrates When | JS Shipped | Use Case | |-----------|--------------|------------|----------| | `client:load` | Immediately on page load | Full bundle | Above-fold interactive (nav, hero CTA) | | `client:idle` | After page is idle (`requestIdleCallback`) | Full bundle | Below-fold interactive (comment form, chat) | | `client:visible` | When scrolled into viewport | Full bundle | Far-down-page (footer widget, carousel) | | `client:media` | When media query matches | Full bundle | Mobile-only nav, responsive components | | `client:only="react"` | Immediately, skip SSR entirely | Full bundle | Components that can't SSR (canvas, WebGL) | | (none) | Never - static HTML only | Zero JS | Static content, cards, headers | ```astro --- import NavBar from '../components/NavBar.tsx'; import CommentForm from '../components/CommentForm.tsx'; import ImageCarousel from '../components/ImageCarousel.svelte'; import MobileMenu from '../components/MobileMenu.vue'; import ThreeScene from '../components/ThreeScene.tsx'; --- <!-- Loads immediately - critical interactivity --> <NavBar client:load /> <!-- Loads after page is idle - non-critical --> <CommentForm client:idle /> <!-- Loads when scrolled into view - lazy --> <ImageCarousel client:visible /> <!-- Loads only on mobile --> <MobileMenu client:media="(max-width: 768px)" /> <!-- Client-only, no SSR (WebGL can't run on server) --> <ThreeScene client:only="react" /> ``` ## Content Collections Quick Start ### Define Schema ```typescript // src/content.config.ts (Astro 5) or src/content/config.ts (Astro 4) import { defineCollection, z, reference } from 'astro:content'; import { glob } from 'astro/loaders'; const blog = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), schema: z.object({ title: z.string(), description: z.string().max(160), 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: reference('authors'), // Reference another collection }), }); const authors = defineCollection({ loader: glob({ pattern: '**/*.json', base: './src/content/authors' }), schema: z.object({ name: z.string(), avatar: z.string(), bio: z.string(), socials: z.object({ twitter: z.string().optional(), github: z.string().optional(), }).optional(), }), }); export const collections = { blog, authors }; ``` ### Query Collections ```astro --- import { getCollection, getEntry } from 'astro:content'; // Get all non-draft blog posts, sorted by date const posts = (await getCollection('blog', ({ data }) => !data.draft)) .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); // Get a single entry const post = await getEntry('blog', 'my-first-post'); // Resolve a reference const author = await getEntry(post.data.author); // Render content const { Content, headings } = await post.render(); --- <Content /> ``` ### Content Collections vs External CMS | Criterion | Content Collections | External CMS (Payload, etc.) | |-----------|--------------------|-----------------------------| | Content type | Local markdown/MDX, docs, blogs | Relational data models | | Authors | Developers (version-controlled) | Editors (admin UI, multi-user auth) | | Validation | Type-safe via Zod at build time | CMS-side schemas + API contracts | | Update cadence | Deploys with the site | Independent of deployments | | API needs | None (build-time queries) | REST/GraphQL for other consumers | | Workflow | Git PRs, simple review | Editorial workflows, drafts, roles | Rule of thumb: start with Content Collections; reach for a CMS only when non-developers need to publish without a deploy, or when content is genuinely relational. ## Project Structure Reference ``` project-root/ ├── astro.config.mjs # Astro configuration ├── tsconfig.json # TypeScript config (extends astro/tsconfigs) ├── package.json ├── public/ # Static assets (copied as-is) │ ├── favicon.svg │ ├── robots.txt │ └── og-image.png ├── src/ │ ├── pages/ # File-based routing │ │ ├── index.astro # → / │ │ ├── about.astro # → /about │ │ ├── blog/ │ │ │ ├── index.astro # → /blog │ │ │ └── [slug].astro # → /blog/:slug (dynamic) │ │ ├── api/ │ │ │ └── search.ts # → /api/search (API endpoint) │ │ └── [...slug].astro # → catch-all/404 │ ├── layouts/ │ │ ├── BaseLayout.astro # HTML shell, <head>, global styles │ │ └── BlogPost.astro # Blog post layout │ ├── components/ │ │ ├── Header.astro # Static Astro component │ │ ├── Footer.astro │ │ ├── NavBar.tsx # React island │ │ └── Counter.svelte # Svelte island │ ├── content/ # Content collections source files │ │ ├── blog/ │ │ │ ├── post-one.md │ │ │ └── post-two.mdx │ │ └── authors/ │ │ └── jane.json │ ├── content.config.ts # Collection schemas (Astro 5) │ ├── middleware.ts # Request/response middleware │ ├── styles/ │ │ └── global.css │ └── lib/ # Shared utilities │ ├── utils.ts │ └── constants.ts └── .env # Environment variables ``` ## View Transitions Quick Reference ```astro --- // src/layouts/BaseLayout.astro import { ViewTransitions } from 'astro:transitions'; --- <html> <head> <ViewTransitions /> </head> <body> <slot /> </body> </html> ``` ### Transition Directives ```astro <!-- Persist element across pages (keeps state, avoids re-render) --> <audio transition:persist id="player"> <source src="/music.mp3" /> </audio> <!-- Named transition for animation pairing --> <img transition:name="hero" src={post.heroImage} /> <!-- Custom animation --> <div transition:animate="slide">Content</div> <div transition:animate="fade">Content</div> <div transition:animate="none">No animation</div> <!-- Persist with name (for multiple persistent elements) --> <video transition:persist="media-player" /> ``` ### Lifecycle Events ```astro <script> document.addEventListener('astro:before-preparation', (e) => { // Before new page is fetched - cancel navigation, show loading }); document.addEventListener('astro:after-preparation', (e) => { // New page fetched, before swap }); document.addEventListener('astro:before-swap', (e) => { // Customize DOM swap behavior }); document.addEventListener('astro:after-swap', () => { // DOM updated - reinitialize scripts }); document.addEventListener('astro:page-load', () => { // Page fully loaded (fires on initial + every navigation) // Use this instead of DOMContentLoaded with View Transitions }); </script> ``` ### Back/Forward Handling ```typescript // astro.config.mjs export default defineConfig({ prefetch: { prefetchAll: true, // Prefetch all links on hover defaultStrategy: 'hover', // 'hover' | 'tap' | 'viewport' | 'load' }, }); ``` ```astro <!-- Per-link prefetch control --> <a href="/about" data-astro-prefetch>Prefetch on hover (default)</a> <a href="/blog" data-astro-prefetch="viewport">Prefetch when visible</a> <a href="/contact" data-astro-prefetch="load">Prefetch immediately</a> <a href="/external" data-astro-prefetch="false">No prefetch</a> ``` ## Deployment Decision Tree ``` Where to deploy? │ ├─ Need edge computing + Cloudflare ecosystem (KV, D1, R2)? │ └─ Cloudflare Pages/Workers │ Adapter: @astrojs/cloudflare │ Best for: Global edge, Workers bindings, cost-effective │ ├─ Need serverless + Vercel ecosystem (ISR, analytics)? │ └─ Vercel │ Adapter: @astrojs/vercel │ Best for: Next.js migration, image optimization, ISR │ ├─ Need serverless + Netlify ecosystem (forms, identity)? │ └─ Netlify │ Adapter: @astrojs/netlify │ Best for: JAMstack, built-in forms, split testing │ ├─ Need full server control (Docker, custom runtime)? │ └─ Node.js (standalone or Express/Fastify) │ Adapter: @astrojs/node │ Best for: Self-hosted, WebSocket, long-running processes │ └─ Pure static site (no SSR needed)? └─ Any static host (GitHub Pages, S3, Cloudflare Pages) No adapter needed, output: 'static' Best for: Blogs, docs, marketing sites ``` ### Adapter Installation ```bash # Cloudflare npx astro add cloudflare # Vercel npx astro add vercel # Netlify npx astro add netlify # Node.js npx astro add node ``` ## Common Gotchas | Gotcha | Why | Fix | |--------|-----|-----| | Hydration mismatch errors | Server HTML differs from client render (dates, random IDs, browser APIs) | Use `client:only` for browser-dependent components, or ensure deterministic rendering | | `import.meta.env` undefined in client | Only `PUBLIC_` prefixed vars are exposed to client-side code | Rename to `PUBLIC_MY_VAR` or pass via props from server | | Dynamic routes 404 in SSG | `getStaticPaths()` not returning all possible params | Ensure `getStaticPaths()` returns every valid path, or switch to hybrid/SSR | | Images not optimizing | Using `<img>` instead of Astro's `<Image />` component | Import from `astro:assets`: `import { Image } from 'astro:assets'` and use local imports for src | | SSR fails without adapter | `output: 'server'` or `'hybrid'` requires a deployment adapter | Install adapter: `npx astro add cloudflare` (or vercel, netlify, node) | | MDX components not rendering | Custom components not passed to MDX content | Pass components via `<Content components={{ MyComponent }} />` or use `astro.config.mjs` MDX config | | Content collection schema changes not reflected | Type generation is cached, stale `.astro` types | Run `astro sync` to regenerate types, restart dev server | | `client:*` on Astro components | Client directives only work on framework components (React, Vue, Svelte) | Astro components are static-only; extract interactive parts to a framework component | | `document` / `window` is not defined | Server-side code cannot access browser globals | Guard with `if (typeof window !== 'undefined')` or move to `client:only` | | Styles leaking between components | Using global CSS instead of scoped styles | Use `<style>` (scoped by default in .astro) or `<style is:global>` intentionally | | View Transitions break scripts | `DOMContentLoaded` only fires once with View Transitions | Use `astro:page-load` event instead, which fires on every navigation | | Env vars missing in production | `.env` not loaded or platform env vars not configured | Use `envField` in astro.config.mjs for validation; set vars in platform dashboard | ## Production Security Checklist For every production deployment, address: - **CSP headers** - configure a restrictive `Content-Security-Policy` (see middleware patterns in `references/deployment.md`) - **Remote image restrictions** - enforce explicit `image.domains` / `remotePatterns` allow-lists; never derive image URLs from user input (SSRF risk) - **Host header validation** - verify the request host matches expected domains in middleware (SSR/hybrid only) - **Secrets management** - on Cloudflare, use Workers Bindings (`wrangler secret put`), not env vars baked into code; elsewhere use platform secret stores - **HTTPS only** - ensure all external resources (scripts, images, fonts) load over HTTPS - **Input validation** - sanitize all user input in SSR contexts (query params, form bodies, cookies) ## Reference Files | File | Contents | Lines | |------|----------|-------| | `references/content-collections.md` | Schema patterns, Zod types, querying, MDX, content layer API, migrations | ~500 | | `references/islands-rendering.md` | Islands deep dive, client directives, framework integration, server islands | ~550 | | `references/deployment.md` | Cloudflare/Vercel/Netlify/Node adapters, env vars, optimization | ~500 | ## See Also - **typescript-ops** - TypeScript patterns used throughout Astro projects - **tailwind-ops** - Tailwind CSS integration with Astro (`@astrojs/tailwind`) - **javascript-ops** - Core JS patterns for client-side island code - **container-orchestration** - Docker patterns for self-hosted Astro (Node adapter) - [Astro Documentation](https://docs.astro.build) - [Astro Integration Guide](https://docs.astro.build/en/guides/integrations-guide/)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.