
Publishing Astro Websites
- 46 installs
- 28 repo stars
- Updated January 2, 2026
- spillwavesolutions/publishing-astro-websites-agentic-skill
Helps with ai & agent building tasks during AI-assisted development.
About
publishing-astro-websites is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- publishing-astro-websites
- AI & Agent Building
- AI-coding skill
Publishing Astro Websites by the numbers
- 46 all-time installs (skills.sh)
- +2 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #7,629 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/spillwavesolutions/publishing-astro-websites-agentic-skill --skill publishing-astro-websitesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 46 |
|---|---|
| repo stars | ★ 28 |
| Last updated | January 2, 2026 |
| Repository | spillwavesolutions/publishing-astro-websites-agentic-skill ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Publishing Astro Websites
Build fast, content-driven static websites with Astro's zero-runtime SSG approach, partial hydration, and extensive Markdown support.
Contents
- Quick Start
- When Not to Use
- Project Structure
- SSG vs SSR vs Hybrid
- Content Collections — Legacy, Content Layer API, Custom Loaders
- Syntax Highlighting — Shiki, Transformers, Expressive Code
- Diagram Integration — Mermaid, PlantUML, Dark Mode Theming
- Client-Side Search — Pagefind (controls, weighting), Fuse.js
- Versioned Documentation — Starlight, Multi-version
- Internationalization — Routing, Fallbacks
- Common Patterns — Pagination, Tags, RSS, Forms
- Performance Best Practices — Prefetching, Critical CSS
- Deployment — Firebase URL Config, GitHub Pages
- Pre-Deploy Checklist
- Testing & Quality — Vitest, Playwright, Link Checking
- Troubleshooting
Quick Start
# Create new project (use Blog template for Markdown sites)
npm create astro@latest
# Development
npm run dev # Local server at http://localhost:4321
npm run build # Generate static files in dist/
npm run preview # Preview production buildWhen Not to Use
This skill focuses on static site generation (SSG). Consider other approaches for:
- Real-time data applications - Use SSR mode with database connections
- User authentication flows - Requires server-side session handling
- E-commerce with dynamic inventory - Use hybrid mode or full SSR
- Single-page applications (SPAs) - Consider React/Vue frameworks directly
For hybrid SSG+SSR patterns, see Astro's adapter documentation.
Project Structure
src/
components/ # Astro, React, Vue, Svelte components
content/ # Content Collections (Markdown/MDX)
config.ts # Collection schemas
docs/ # Example collection
layouts/ # Page wrappers with slots
pages/ # File-based routing
public/ # Static assets (images, fonts, favicons)
astro.config.mjs # Framework configurationSSG vs SSR vs Hybrid
| Mode | When Pages Render | Use Case |
|---|---|---|
| SSG (default) | Build time | Blogs, docs, marketing sites |
| SSR | Each request | Dynamic data, personalization |
| Hybrid | Mix of both | Static pages + dynamic endpoints |
For pure static sites, use default output: 'static' - no adapter needed.
Content Collections
Legacy Pattern (Astro 4.x)
Define schemas in src/content/config.ts:
import { defineCollection, z } from "astro:content";
export const collections = {
docs: defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
order: z.number().optional(),
draft: z.boolean().default(false)
})
})
};Content Layer API (Astro 5.0+)
New pattern with glob() loader - up to 75% faster builds for large sites:
// src/content.config.ts (note: different filename)
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: ({ image }) => z.object({
title: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
cover: image(), // Validates image exists
author: reference('authors'), // Cross-collection reference
})
});
export const collections = { blog };Advanced Schema Patterns
schema: ({ image }) => z.object({
cover: image(), // Validates image in src/
category: z.enum(['tech', 'news']),
author: reference('authors'), // Cross-collection ref
relatedPosts: z.array(reference('blog')).optional(),
})Custom Loaders (Remote Content)
Fetch content from external APIs (GitHub releases, CMS, etc.):
// src/loaders/github-releases.ts
import type { Loader } from 'astro/loaders';
export function githubReleasesLoader(repo: string): Loader {
return {
name: 'github-releases',
load: async ({ store, logger }) => {
logger.info(`Fetching releases for ${repo}`);
const response = await fetch(`https://api.github.com/repos/${repo}/releases`);
const releases = await response.json();
for (const release of releases) {
store.set({
id: release.tag_name,
data: {
version: release.tag_name,
published_at: release.published_at,
body: release.body // Markdown release notes
}
});
}
}
};
}Register in content.config.ts:
import { githubReleasesLoader } from './loaders/github-releases';
const releases = defineCollection({
loader: githubReleasesLoader('owner/repo'),
schema: z.object({
version: z.string(),
published_at: z.string(),
body: z.string(),
})
});Query and render collections:
---
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const docs = await getCollection("docs");
return docs.map(doc => ({
params: { slug: doc.slug },
props: { doc }
}));
}
const { doc } = Astro.props;
const { Content } = await doc.render();
---
<article>
<h1>{doc.data.title}</h1>
<Content />
</article>Syntax Highlighting
Basic Shiki Configuration
import { defineConfig } from "astro/config";
export default defineConfig({
markdown: {
shikiConfig: {
theme: "github-dark",
wrap: true
}
}
});Dual Light/Dark Theme
shikiConfig: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
}Add CSS to switch themes:
@media (prefers-color-scheme: dark) {
.astro-code, .astro-code span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
}Line Highlighting and Transformers
~~~markdown ```typescript {2,4} const a = 1; const b = 2; // highlighted const c = 3; console.log(a + b + c); // highlighted
~~~
**Shiki Transformers (Astro 4.14+):**
import { transformerNotationFocus, transformerNotationDiff } from '@shikijs/transformers';
shikiConfig: { transformers: [transformerNotationFocus(), transformerNotationDiff()], }
Use notation comments in code:
- `// [!code focus]` - Focus this line
- `// [!code ++]` - Mark as addition (green)
- `// [!code --]` - Mark as deletion (red)
### Expressive Code (Recommended for Docs)
Rich code blocks with copy buttons, filenames, diff highlighting:
npm install astro-expressive-code
import expressiveCode from 'astro-expressive-code';
export default defineConfig({ integrations: [expressiveCode()], });
Features: Copy button, file tabs, line markers, terminal frames, text markers.
## Diagram Integration
### Mermaid (Recommended)
Install the Astro integration:
npm install astro-mermaid mermaid
// astro.config.mjs import { defineConfig } from 'astro/config'; import mermaid from 'astro-mermaid';
export default defineConfig({ integrations: [mermaid({ theme: 'default' })] });
Use in Markdown:
~~~markdowngraph TD; A-->B; B-->C;
~~~
Features: Client-side rendering, automatic theme switching, offline capable, no Playwright required.
**Alternative (build-time static SVG):** Use `rehype-mermaid` with Playwright for pre-rendered diagrams (`npx playwright install --with-deps` required).
**Dark Mode Theming Strategies:**
1. **CSS Variables** - Let browser resolve colors at runtime:// mermaid config mermaid.initialize({ theme: 'base', themeVariables: { primaryColor: 'var(--diagram-primary)', lineColor: 'var(--diagram-line)' } });
2. **Picture Element** - Generate both themes, swap with media query:<picture> <source srcset="/diagrams/flow-dark.svg" media="(prefers-color-scheme: dark)"> <img src="/diagrams/flow-light.svg" alt="Flow diagram"> </picture>
3. **Inline SVG** - Target SVG classes with CSS (risk: style collisions):.dark .mermaid-svg .node rect { fill: var(--bg-dark); }
### PlantUML
npx astro add plantuml
Use in Markdown:
~~~markdown@startuml Alice -> Bob: Hello Bob --> Alice: Hi! @enduml
~~~
## Client-Side Search
### Pagefind (Recommended for Large Sites)
Zero-config static search that indexes at build time:
npm install pagefind
Add to `package.json`:
{ "scripts": { "build": "astro build && npx pagefind --site dist", "postbuild": "pagefind --site dist" } }
Use in components:
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" /> <script src="/pagefind/pagefind-ui.js" type="text/javascript"></script>
<div id="search"></div> <script> window.addEventListener('DOMContentLoaded', () => { new PagefindUI({ element: '#search', showSubResults: true }); }); </script>
Features: No external service, works offline, automatic indexing, small bundle (~10KB).
**Granular Indexing Control:**
<!-- Only index main content, not headers/sidebars --> <main data-pagefind-body> <h1 data-pagefind-meta="title">Page Title</h1> <p data-pagefind-weight="10">Important intro text</p>
<!-- Exclude from search snippets --> <nav data-pagefind-ignore> <a href="/related">Related Posts</a> </nav> </main>
| Attribute | Purpose |
|-----------|---------|
| `data-pagefind-body` | Limit indexing to this element only |
| `data-pagefind-ignore` | Exclude element from index |
| `data-pagefind-meta="key"` | Define metadata field |
| `data-pagefind-weight="10"` | Boost relevance (default: 1) |
### Pagefind vs Fuse.js
| Feature | Pagefind | Fuse.js |
|---------|----------|---------|
| Architecture | Pre-built binary chunks | Runtime in-memory |
| Bandwidth | Low (loads only needed chunks) | High (downloads full index) |
| Scalability | 10,000+ pages | < 500 pages |
| Multilingual | Native stemming | Manual config |
| Use Case | Global site search | Small list filtering |
### Fuse.js (Lightweight Alternative)
For smaller sites with custom UI needs:
--- import { getCollection } from "astro:content"; const posts = await getCollection('blog'); const searchIndex = JSON.stringify(posts.map(post => ({ title: post.data.title, slug: post.slug, body: post.body.slice(0, 500) }))); ---
<input type="search" id="search" placeholder="Search..." /> <ul id="results"></ul>
<script define:vars={{ searchIndex }}> import Fuse from 'fuse.js';
const fuse = new Fuse(JSON.parse(searchIndex), { keys: ['title', 'body'], threshold: 0.3 });
document.getElementById('search').addEventListener('input', (e) => { const results = fuse.search(e.target.value); const resultsEl = document.getElementById('results');
// Clear previous results safely resultsEl.replaceChildren();
// Build results using safe DOM methods results.forEach(r => { const li = document.createElement('li'); const link = document.createElement('a'); link.href = /blog/${r.item.slug}; link.textContent = r.item.title; li.appendChild(link); resultsEl.appendChild(li); }); }); </script>
For enterprise needs, consider Algolia (hosted search API).
## Versioned Documentation
### Starlight (Recommended for Docs Sites)
Purpose-built documentation framework on Astro:
npm create astro@latest -- --template starlight
Key features: Built-in search (Pagefind), i18n, sidebar navigation, dark mode, component overrides.
// astro.config.mjs import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight';
export default defineConfig({ integrations: [ starlight({ title: 'My Docs', sidebar: [ { label: 'Guides', autogenerate: { directory: 'guides' } }, { label: 'Reference', autogenerate: { directory: 'reference' } }, ], }), ], });
### Multi-Version Docs Pattern
Use folder-based structure:
src/content/docs/ v1/ getting-started.md api-reference.md v2/ getting-started.md api-reference.md new-feature.md
For Starlight versioning, use `starlight-utils` plugin:
npm install starlight-utils
import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; import starlightUtils from 'starlight-utils';
export default defineConfig({ integrations: [ starlight({ plugins: [starlightUtils({ multiSidebar: { switcherStyle: 'dropdown' } })], sidebar: [ { label: 'v2', items: [{ label: 'Guides', autogenerate: { directory: 'v2' } }] }, { label: 'v1', items: [{ label: 'Guides', autogenerate: { directory: 'v1' } }] }, ], }), ], });
## Internationalization (i18n)
Configure in `astro.config.mjs`:
export default defineConfig({ i18n: { defaultLocale: "en", locales: ["en", "fr", "es"], routing: { prefixDefaultLocale: false } } });
Structure content by locale:
src/content/docs/ en/ getting-started.md fr/ getting-started.md
Detect locale in components:
--- const locale = Astro.currentLocale || 'en'; ---
### Fallback for Missing Translations
Show English content with a banner when translations don't exist:
--- // src/pages/[lang]/[...slug].astro import { getCollection, getEntry } from "astro:content";
const languages = ['en', 'es', 'fr']; const defaultLang = 'en';
export async function getStaticPaths() { const englishDocs = await getCollection('docs', ({ id }) => id.startsWith('en/')); const paths = [];
for (const doc of englishDocs) { const slug = doc.id.replace(/^en\//, '');
for (const lang of languages) { const localizedId = ${lang}/${slug}; const localizedDoc = await getEntry('docs', localizedId);
paths.push({ params: { lang, slug }, props: { entry: localizedDoc || doc, // Fallback to English isFallback: !localizedDoc } }); } } return paths; }
const { entry, isFallback } = Astro.props; const { Content } = await entry.render(); ---
{isFallback && ( <div class="translation-notice"> This page is not yet available in your language. </div> )} <Content />
## Common Patterns
### Paginated Listings
For sites with 50+ posts, split into pages:
--- // src/pages/blog/page/[page].astro import { getCollection } from "astro:content";
const POSTS_PER_PAGE = 10;
export async function getStaticPaths() { const allPosts = await getCollection("blog"); const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
return Array.from({ length: totalPages }, (_, i) => ({ params: { page: String(i + 1) }, })); }
const { page } = Astro.params; const pageNum = parseInt(page); const allPosts = await getCollection("blog"); const sortedPosts = allPosts.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime() );
const start = (pageNum - 1) * POSTS_PER_PAGE; const posts = sortedPosts.slice(start, start + POSTS_PER_PAGE); const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE); ---
{posts.map(post => <article>{post.data.title}</article>)}
<nav> {pageNum > 1 && <a href={/blog/page/${pageNum - 1}}>← Previous</a>} <span>Page {pageNum} of {totalPages}</span> {pageNum < totalPages && <a href={/blog/page/${pageNum + 1}}>Next →</a>} </nav>
### Tag/Category Archives
Generate a page for each tag:
--- // src/pages/tags/[tag].astro import { getCollection } from "astro:content";
export async function getStaticPaths() { const allPosts = await getCollection("blog"); const tags = new Set(); allPosts.forEach(post => post.data.tags?.forEach(tag => tags.add(tag)));
return Array.from(tags).map(tag => ({ params: { tag }, props: { tag }, })); }
const { tag } = Astro.props; const allPosts = await getCollection("blog"); const postsWithTag = allPosts.filter(post => post.data.tags?.includes(tag)); ---
<h1>Posts tagged: {tag}</h1> {postsWithTag.map(post => <a href={/blog/${post.slug}}>{post.data.title}</a>)}
### RSS Feed
// src/pages/rss.xml.js import rss from "@astrojs/rss"; import { getCollection } from "astro:content";
export async function GET(context) { const blog = await getCollection("blog"); return rss({ title: "My Blog", description: "A blog about Astro", site: context.site, items: blog.map(post => ({ title: post.data.title, description: post.data.description, pubDate: post.data.pubDate, link: /blog/${post.slug}/, })), }); }
### Static Forms
For SSG sites, use third-party form handlers:
**Formspree (Easiest):**<form action="https://formspree.io/f/YOUR_FORM_ID" method="POST"> <input type="text" name="name" required /> <input type="email" name="email" required /> <textarea name="message" required></textarea> <button type="submit">Send</button> </form>
**Netlify Forms:**<form name="contact" method="POST" data-netlify="true"> <input type="hidden" name="form-name" value="contact" /> <input type="text" name="name" required /> <button type="submit">Send</button> </form>
### JSON-LD Structured Data
Add rich snippets for SEO:
--- const jsonLd = { "@context": "https://schema.org", "@type": "BlogPosting", headline: entry.data.title, description: entry.data.description, datePublished: entry.data.pubDate?.toISOString(), author: { "@type": "Person", name: entry.data.author }, }; ---
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
### Dark Mode Toggle
<button id="theme-toggle">🌙</button>
<script> const toggle = document.getElementById("theme-toggle"); const html = document.documentElement;
// Load saved preference or detect system preference const savedTheme = localStorage.getItem("theme"); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (savedTheme === "dark" || (!savedTheme && prefersDark)) { html.classList.add("dark"); }
toggle?.addEventListener("click", () => { html.classList.toggle("dark"); localStorage.setItem("theme", html.classList.contains("dark") ? "dark" : "light"); }); </script>
With Tailwind, enable `darkMode: "class"` in config.
## Performance Best Practices
1. **Partial Hydration**: Use `client:*` directives only where needed
- `client:load` - Hydrate immediately
- `client:idle` - Hydrate when browser is idle
- `client:visible` - Hydrate when in viewport
- `client:media` - Hydrate on media query match
- `client:only="react"` - Skip server render, client-only
2. **Image Optimization**: Use Astro's `<Image />` component
3. **Keep Static Where Possible**: Islands architecture means most content remains static HTML
4. **Asset Fingerprinting**: Automatic in production builds
5. **Prefetching**: Auto-load links before user clicks
// astro.config.mjs export default defineConfig({ prefetch: { prefetchAll: true, // Prefetch all links defaultStrategy: 'viewport' // When links enter viewport } });
Options: `'tap'` (on hover/focus), `'viewport'` (when visible), `'load'` (on page load).
6. **Critical CSS**: Inline above-the-fold CSS with astro-critters
npm install astro-critters
import critters from 'astro-critters';
export default defineConfig({ integrations: [critters()] });
Extracts critical CSS and inlines it, deferring the rest for faster first paint.
## Deployment
### Deployment Workflow
1. **Build**: Run `npm run build` and verify `dist/` output
2. **Preview**: Test with `npm run preview` at localhost:4321
3. **Configure**: Set `site`, `base`, and `trailingSlash` in astro.config.mjs
4. **Platform Setup**: Initialize hosting (e.g., `firebase init hosting`)
5. **Deploy**: Push to platform (`firebase deploy`, `vercel`, or git push)
6. **Verify**: Check live URL, test 404 page, validate assets load
### Quick Deploy Commands
Build for production
npm run build
Preview before deploy
npm run preview
### Platform-Specific
**Netlify/Vercel/Cloudflare Pages:**
Connect Git repository - auto-deploys on push.
**GitHub Pages:**// astro.config.mjs export default defineConfig({ site: 'https://username.github.io', base: '/repo-name' });
**Firebase Hosting:**npm install -g firebase-tools firebase login firebase init hosting # Set public to 'dist' npm run build firebase deploy
`firebase.json` (recommended configuration):{ "hosting": { "public": "dist", "ignore": ["firebase.json", "*/."], "cleanUrls": true, "trailingSlash": false, "headers": [ { "source": "/_astro/**", "headers": [{"key": "Cache-Control", "value": "public, max-age=31536000, immutable"}] } ] } }
**Align Astro config** to prevent redirect loops:// astro.config.mjs - match Firebase settings export default defineConfig({ trailingSlash: 'never', // Must match Firebase trailingSlash: false build: { format: 'directory' // Default - generates /about/index.html } });
| Firebase Setting | Astro Setting | Result |
|------------------|---------------|--------|
| `trailingSlash: false` | `trailingSlash: 'never'` | `/about` (no slash) |
| `trailingSlash: true` | `trailingSlash: 'always'` | `/about/` (with slash) |
| Mismatch | Mismatch | Redirect loops! |
### Common Deployment Gotchas
| Issue | Solution |
|-------|----------|
| Trailing slash problems | Set `trailingSlash: 'always'` or `'never'` |
| Assets not loading on subpath | Configure `base` in astro.config.mjs |
| 404 not working | Create custom `404.astro` page |
| Build fails on deploy | Check Node version matches local |
## Pre-Deploy Checklist
Before deploying, verify:
- [ ] `npm run build` completes without errors
- [ ] `npm run preview` shows site correctly at localhost:4321
- [ ] All Content Collection schemas validate (`astro check`)
- [ ] Images use `<Image />` component or are in `public/`
- [ ] SEO metadata present on all pages (title, description, og:*)
- [ ] 404.astro page exists and renders correctly
- [ ] `base` path configured if deploying to subdirectory
- [ ] Environment variables set on deployment platform
- [ ] `trailingSlash` setting matches hosting platform expectations
- [ ] RSS feed working (`/rss.xml`)
- [ ] Sitemap generated (`/sitemap-index.xml`)
- [ ] Lighthouse score > 90
- [ ] Component tests pass (`npm run test`)
- [ ] E2E tests pass (`npm run test:e2e`)
- [ ] Link checker finds no broken links (`npx linkinator dist`)
## Testing & Quality
### Static Analysis
Type checking and validation
npx astro check
Linting (with ESLint)
npm install -D eslint npx eslint .
Preview production build
npm run build && npm run preview
**Build-time validation** happens automatically with Content Collections - schema errors fail the build.
### Component Testing with Vitest
npm install -D vitest @vitest/ui @astrojs/testing
// vitest.config.ts import { getViteConfig } from 'astro/config';
export default getViteConfig({ test: { include: ['src/*/.test.ts'], }, });
// src/components/Button.test.ts import { experimental_AstroContainer as AstroContainer } from 'astro/container'; import { expect, test } from 'vitest'; import Button from './Button.astro';
test('Button renders with text', async () => { const container = await AstroContainer.create(); const result = await container.renderToString(Button, { props: { text: 'Click me' } }); expect(result).toContain('Click me'); });
### E2E Testing with Playwright
npm install -D @playwright/test npx playwright install
// tests/homepage.spec.ts import { test, expect } from '@playwright/test';
test('homepage loads correctly', async ({ page }) => { await page.goto('/'); await expect(page).toHaveTitle(/My Site/); await expect(page.locator('h1')).toBeVisible(); });
test('navigation works', async ({ page }) => { await page.goto('/'); await page.click('a[href="/about"]'); await expect(page).toHaveURL(/about/); });
// package.json { "scripts": { "test": "vitest", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" } }
### Link Checking
Validate internal links don't break:
npm install -D linkinator npx linkinator dist --recurse
Add to CI:
.github/workflows/links.yml
- run: npm run build
- run: npx linkinator dist --recurse --skip "^(?!http://localhost)"
## .astro File Anatomy
--- // Frontmatter: JavaScript/TypeScript runs at build time import Layout from '../layouts/Layout.astro'; import { getCollection } from 'astro:content';
const { title } = Astro.props; const posts = await getCollection('blog'); ---
<!-- Template: HTML with JSX expressions --> <Layout title={title}> <h1>{title}</h1> <ul> {posts.map(post => ( <li><a href={/blog/${post.slug}}>{post.data.title}</a></li> ))} </ul> </Layout>
<style> / Scoped to this component / h1 { color: navy; } </style>
<script> // Client-side JavaScript console.log('Runs in browser'); </script>
## File-Based Routing
| File | Route |
|------|-------|
| `src/pages/index.astro` | `/` |
| `src/pages/about.astro` | `/about` |
| `src/pages/blog/index.astro` | `/blog` |
| `src/pages/blog/[slug].astro` | `/blog/:slug` (dynamic) |
| `src/pages/[...path].astro` | Catch-all |
Dynamic routes require `getStaticPaths()` for SSG:
--- export function getStaticPaths() { return [ { params: { slug: 'post-1' } }, { params: { slug: 'post-2' } } ]; } ---
## SEO Essentials
### Manual Approach
--- const { title, description, image } = Astro.props; const canonicalURL = new URL(Astro.url.pathname, Astro.site); ---
<head> <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={image} /> <meta property="og:type" content="website" />
<!-- Twitter --> <meta name="twitter:card" content="summary_large_image" /> </head>
### astro-seo (Simplified)
npm install astro-seo
--- import { SEO } from 'astro-seo'; ---
<head> <SEO title="Page Title" description="Page description" openGraph={{ basic: { title: "OG Title", type: "website", image: "/og-image.png", } }} twitter={{ creator: "@handle" }} /> </head>
Handles meta tags, Open Graph, Twitter Cards, and canonical URLs automatically.
## Essential Integrations
Add integrations
npx astro add react # React components npx astro add tailwind # Tailwind CSS npx astro add mdx # MDX support npx astro add sitemap # Auto-generate sitemap
RSS feed
npm install @astrojs/rss
## Troubleshooting
**"Works locally but breaks on deploy"**
- Check environment variables are set on host
- Verify `base` path configuration
- Ensure Node version matches (v18+ recommended)
**Dynamic routes missing pages**
- Verify `getStaticPaths()` returns all needed paths
- Check for typos in params
**Content Collection schema errors**
- Run `astro check` for validation details
- Ensure frontmatter matches Zod schema exactly
**Assets not loading**
- Use `import` for processed assets
- Use `public/` for unprocessed static files
## References
For detailed guides on specific topics, see:
- `references/markdown-deep-dive.md` - Advanced Markdown/MDX patterns
- `references/deployment-platforms.md` - Platform-specific deployment details
## Key Resources
- [Astro Documentation](https://docs.astro.build/)
- [Content Collections](https://docs.astro.build/en/guides/content-collections/)
- [Markdown in Astro](https://docs.astro.build/en/guides/markdown-content/)
- [Deploy Astro](https://docs.astro.build/en/guides/deploy/)
- [Starlight Docs Theme](https://starlight.astro.build/)
Deployment Platforms for Astro Static Sites
Comprehensive deployment guides for major hosting platforms.
Platform Comparison
| Platform | Auto CI/CD | Custom Domain | Edge CDN | Free Tier |
|---|---|---|---|---|
| Netlify | Yes | Yes | Yes | 100GB/mo |
| Vercel | Yes | Yes | Yes | 100GB/mo |
| Cloudflare Pages | Yes | Yes | Yes | Unlimited |
| GitHub Pages | Manual | Yes | Via CDN | Unlimited |
| Firebase Hosting | Manual | Yes | Yes | 10GB/mo |
| AWS S3+CloudFront | Manual | Yes | Yes | Pay-as-go |
Netlify
Quick Setup
1. Push code to GitHub/GitLab 2. Connect repository in Netlify dashboard 3. Set build command: npm run build 4. Set publish directory: dist
netlify.toml
[build]
command = "npm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "18"
# Redirects for SPA-style routing (if needed)
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Custom headers
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
# Trailing slashes
[build.processing]
skip_processing = false
[build.processing.html]
pretty_urls = trueEnvironment Variables
Set in Netlify dashboard or netlify.toml:
[context.production.environment]
API_URL = "https://api.example.com"
[context.deploy-preview.environment]
API_URL = "https://staging-api.example.com"Vercel
Quick Setup
1. Install Vercel CLI: npm i -g vercel 2. Run vercel in project root 3. Follow prompts to link/create project
vercel.json
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"framework": "astro",
"routes": [
{
"src": "/assets/(.*)",
"headers": {
"Cache-Control": "public, max-age=31536000, immutable"
}
}
],
"trailingSlash": false
}Preview Deployments
Automatic for every PR. Access via unique URLs like: project-git-branch-username.vercel.app
Cloudflare Pages
Quick Setup
1. Connect GitHub repo in Cloudflare dashboard 2. Set framework preset: Astro 3. Build command auto-detected
Configuration
# wrangler.toml (optional)
name = "my-astro-site"
compatibility_date = "2024-01-01"
[site]
bucket = "./dist"Custom Headers
Create public/_headers:
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
/assets/*
Cache-Control: public, max-age=31536000, immutableGitHub Pages
GitHub Actions Workflow
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
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
cache: npm
- 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:
- uses: actions/deploy-pages@v4
id: deploymentAstro Configuration for Subpath
// astro.config.mjs
export default defineConfig({
site: 'https://username.github.io',
base: '/repository-name'
});Firebase Hosting
Initial Setup
# Install Firebase CLI
npm install -g firebase-tools
# Login and initialize
firebase login
firebase init hostingfirebase.json
{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [],
"headers": [
{
"source": "/assets/**",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "**",
"headers": [
{
"key": "X-Frame-Options",
"value": "DENY"
}
]
}
],
"cleanUrls": true,
"trailingSlash": false
}
}Deploy Commands
# Build and deploy
npm run build
firebase deploy --only hosting
# Deploy to preview channel
firebase hosting:channel:deploy preview-name
# Deploy with custom site ID (multiple sites)
firebase deploy --only hosting:my-site-idGitHub Actions for Firebase
# .github/workflows/firebase-deploy.yml
name: Deploy to Firebase
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
channelId: live
projectId: your-project-idMultiple Sites
{
"hosting": [
{
"target": "main-site",
"public": "dist",
"ignore": ["firebase.json", "**/.*"]
},
{
"target": "docs-site",
"public": "docs/dist",
"ignore": ["firebase.json", "**/.*"]
}
]
}firebase target:apply hosting main-site my-main-site
firebase target:apply hosting docs-site my-docs-site
firebase deploy --only hostingAWS S3 + CloudFront
S3 Bucket Setup
# Create bucket
aws s3 mb s3://my-astro-site
# Enable static website hosting
aws s3 website s3://my-astro-site \
--index-document index.html \
--error-document 404.html
# Sync build output
aws s3 sync dist/ s3://my-astro-site --deleteBucket Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-astro-site/*"
}
]
}CloudFront Distribution
Key settings:
- Origin: S3 bucket website endpoint
- Default root object:
index.html - Custom error response: 404 → /404.html
- Cache behavior: Use cache policy for static assets
Invalidation on Deploy
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/*"GCS + Cloud CDN (GCP Alternative)
Alternative to Firebase Hosting using Google Cloud Storage with Cloud CDN.
Setup
# Create bucket with uniform access
gsutil mb -l us-central1 gs://my-astro-site
gsutil uniformbucketlevelaccess set on gs://my-astro-site
# Enable public access
gsutil iam ch allUsers:objectViewer gs://my-astro-site
# Configure as static site
gsutil web set -m index.html -e 404.html gs://my-astro-siteDeploy Script
# Build and sync
npm run build
gsutil -m rsync -r -d dist/ gs://my-astro-site
# Set cache headers for assets
gsutil -m setmeta -h "Cache-Control:public, max-age=31536000" \
gs://my-astro-site/_astro/**Cloud CDN Setup
1. Create a backend bucket in Cloud Console 2. Point it to your GCS bucket 3. Create a load balancer with the backend bucket 4. Enable Cloud CDN on the backend bucket 5. Configure SSL certificate for HTTPS
# Invalidate CDN cache after deploy
gcloud compute url-maps invalidate-cdn-cache my-lb \
--path "/*" --asyncWhen to Use GCS + CDN vs Firebase
| Scenario | Recommendation |
|---|---|
| Simple static site | Firebase Hosting |
| Need fine-grained cache control | GCS + CDN |
| Already using GCP extensively | GCS + CDN |
| Want simplest setup | Firebase Hosting |
| Need custom CDN configuration | GCS + CDN |
Common Issues and Solutions
Trailing Slashes
// astro.config.mjs
export default defineConfig({
trailingSlash: 'always' // or 'never' or 'ignore'
});Base Path for Subdirectory
// astro.config.mjs
export default defineConfig({
site: 'https://example.com',
base: '/my-app'
});Update all internal links:
<a href={`${import.meta.env.BASE_URL}about`}>About</a>404 Handling
Most platforms need explicit 404 configuration:
1. Create src/pages/404.astro 2. Configure platform-specific redirects
Build Failures
Common causes:
- Node version mismatch (use v18+)
- Missing environment variables
- Case-sensitive file systems (Linux vs macOS)
- Memory limits for large sites
Asset Caching
For optimal performance, ensure assets have cache headers:
// astro.config.mjs
export default defineConfig({
build: {
assets: '_assets' // Prefixed directory for fingerprinted assets
}
});Performance Optimization
Pre-compression
// astro.config.mjs
import compress from 'astro-compress';
export default defineConfig({
integrations: [compress()]
});Image Optimization
// astro.config.mjs
export default defineConfig({
image: {
service: {
entrypoint: 'astro/assets/services/sharp'
}
}
});Prefetching
// astro.config.mjs
export default defineConfig({
prefetch: {
prefetchAll: true,
defaultStrategy: 'viewport'
}
});Markdown Deep Dive for Astro
Advanced patterns for Markdown and MDX content in Astro static sites.
Static Markdown Rendering
Astro treats Markdown as build-time content with zero runtime cost:
- Parsed and transformed to HTML during build
- Excellent SEO out of the box
- Full control over layout and styling
- Clean separation of content and presentation
Content Collection Best Practices
Schema Patterns
// src/content/config.ts
import { defineCollection, z, reference } from "astro:content";
export const collections = {
// Blog with author references
blog: defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
author: reference('authors'), // Reference another collection
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false)
})
}),
// Authors collection
authors: defineCollection({
schema: z.object({
name: z.string(),
avatar: z.string(),
bio: z.string()
})
}),
// Documentation with ordering
docs: defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
sidebar: z.object({
order: z.number(),
label: z.string().optional()
}).optional()
})
})
};Filtering Collections
---
import { getCollection } from "astro:content";
// Filter out drafts in production
const publishedPosts = await getCollection('blog', ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
// Sort by date
const sortedPosts = publishedPosts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
// Filter by tag
const rustPosts = await getCollection('blog', ({ data }) => {
return data.tags.includes('rust');
});
---Advanced Code Block Features
Shiki Configuration Options
// astro.config.mjs
export default defineConfig({
markdown: {
shikiConfig: {
// Available themes: https://shiki.style/themes
theme: 'github-dark',
// Or use dual themes for light/dark mode
themes: {
light: 'github-light',
dark: 'github-dark'
},
// Enable word wrap
wrap: true,
// Add custom languages
langs: []
}
}
});Line Highlighting Syntax
<!-- Highlight lines 2-4 -->const a = 1; const b = 2; const c = 3; const d = 4;
<!-- Highlight with markers -->const original = 1; const modified = 2; // highlighted const added = 3; // green (inserted) const removed = 4; // red (deleted)
Code Block Titles
export function helper() { return 'Hello'; }
MDX Integration
Install and configure:
npx astro add mdx// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
export default defineConfig({
integrations: [mdx()]
});Using Components in MDX
---
title: My Article
---
import Callout from '../components/Callout.astro';
import CodeDemo from '../components/CodeDemo.jsx';
# {frontmatter.title}
<Callout type="warning">
This is an important warning!
</Callout>
Here's an interactive demo:
<CodeDemo client:visible />Component Mapping
Override default HTML elements:
---
// src/pages/blog/[slug].astro
import { getEntry } from 'astro:content';
import CustomHeading from '../components/CustomHeading.astro';
import CustomCode from '../components/CustomCode.astro';
const { slug } = Astro.params;
const post = await getEntry('blog', slug);
const { Content } = await post.render();
---
<Content components={{
h1: CustomHeading,
h2: CustomHeading,
pre: CustomCode
}} />Remark and Rehype Plugins
Common Plugin Setup
// astro.config.mjs
import { defineConfig } from 'astro/config';
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({
markdown: {
remarkPlugins: [
remarkToc,
remarkMath
],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
rehypeKatex
]
}
});Reading Time Plugin
// plugins/remark-reading-time.mjs
import getReadingTime from 'reading-time';
import { toString } from 'mdast-util-to-string';
export function remarkReadingTime() {
return function (tree, { data }) {
const textOnPage = toString(tree);
const readingTime = getReadingTime(textOnPage);
data.astro.frontmatter.minutesRead = readingTime.text;
};
}// astro.config.mjs
import { remarkReadingTime } from './plugins/remark-reading-time.mjs';
export default defineConfig({
markdown: {
remarkPlugins: [remarkReadingTime]
}
});Table of Contents Generation
Manual Approach
---
import { getEntry } from 'astro:content';
const post = await getEntry('blog', 'my-post');
const { Content, headings } = await post.render();
// headings is an array of { depth, slug, text }
---
<nav class="toc">
<h2>On This Page</h2>
<ul>
{headings
.filter(h => h.depth <= 3)
.map(h => (
<li style={`margin-left: ${(h.depth - 2) * 1rem}`}>
<a href={`#${h.slug}`}>{h.text}</a>
</li>
))
}
</ul>
</nav>
<article>
<Content />
</article>Images in Markdown
Local Images with Optimization
<!-- Use relative paths for optimization -->

<!-- Or import in MDX -->
import heroImage from './hero.png';
<img src={heroImage.src} alt="Hero" />Remote Images
// astro.config.mjs
export default defineConfig({
image: {
domains: ['images.unsplash.com'],
remotePatterns: [{ protocol: 'https' }]
}
});---
import { Image } from 'astro:assets';
---
<Image
src="https://images.unsplash.com/photo-..."
alt="Description"
width={800}
height={600}
/>Custom Frontmatter Fields
Access custom fields in rendered content:
---
title: My Post
customField: Special Value
------
const post = await getEntry('blog', 'my-post');
console.log(post.data.customField); // "Special Value"
---Performance Considerations
1. Build-time only: All Markdown processing happens at build 2. No runtime parsing: Output is pure HTML 3. Syntax highlighting: Pre-computed, no client-side JS 4. Lazy loading: Use loading="lazy" for images 5. Content caching: Astro caches processed Markdown between builds
Troubleshooting Markdown Issues
Frontmatter validation errors:
astro check # Shows detailed schema errorsCode block not highlighting:
- Ensure language tag is specified: \
\\`javascript - Check theme is properly configured
MDX components not rendering:
- Verify component is imported at top of file
- Check component path is correct
- Ensure MDX integration is installed
Slow builds with many Markdown files:
- Consider incremental builds
- Use
content.config.tstype generation caching