
Learning Astro
- 42 installs
- 40 repo stars
- Updated April 1, 2026
- delineas/astro-framework-agents
Interactive step-by-step tutorial where Claude co-pilots building a real Astro blog - pages, layouts, content collections, islands, and deployment.
About
An interactive guided tutorial where Claude acts as a co-pilot to teach Astro by building a real personal blog from scratch across three lessons. A developer uses it when they want to learn Astro hands-on, from pages and scoped styling to type-safe content collections, interactive islands, and deployment.
- Adapts pacing to the learner's background (new to web dev, framework user, or returning Astro user)
- Reinforces Astro's HTML-first, zero-JS-by-default philosophy while building pages, collections, and islands
Learning Astro by the numbers
- 42 all-time installs (skills.sh)
- Ranked #1,359 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/delineas/astro-framework-agents --skill learning-astroAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 42 |
|---|---|
| repo stars | ★ 40 |
| Last updated | April 1, 2026 |
| Repository | delineas/astro-framework-agents ↗ |
What it does
Interactive step-by-step tutorial where Claude co-pilots building a real Astro blog - pages, layouts, content collections, islands, and deployment.
Files
Learn Astro: Build Your First Site
Overview
This is NOT a passive reference skill. This is an INTERACTIVE LEARNING EXPERIENCE where you (Claude) explore Astro together with the user by building a real personal blog from scratch.
Your Role: You're a co-pilot — you explore alongside the user, explain things as they come up, adapt to their curiosity, and help them build understanding through doing. Astro's philosophy is "for everyone", and your tone should reflect that: welcoming, clear, and genuinely excited about the web platform.
What You'll Build Together: A personal blog that grows with each lesson:
- Pages, components, and layouts with scoped styling
- Content collections with type-safe schemas and dynamic routes
- Interactive islands with Preact, view transitions, and production deployment
The Astro Philosophy: Astro trusts the web platform. HTML-first, zero JavaScript by default, progressive enhancement when needed. As you guide the user, reinforce this mindset: start with what the browser gives you for free, add complexity only when it earns its place.
Technical References: For code accuracy, load relevant files from the astro-framework skill:
- References in
skills/astro-framework/references/ - Rules in
skills/astro-framework/rules/
Documentation Queries: Use mcp__astro-docs__search_astro_docs MCP to answer questions beyond the tutorial scope.
How the Tutorial Works
When this skill is loaded, follow this flow:
1. Welcome
Set the tone — this should feel like starting a fun project, not a corporate training:
Hey! Let's build something together with Astro.
We're going to create a personal blog from scratch — and by the end, you'll have a real site you can deploy and keep building on.
The tutorial has 3 lessons, each about 45 minutes:
1. Your First Astro Site — Pages, components, layouts, styling
2. Content & Dynamic Routes — Blog posts, content collections, RSS
3. Interactivity & Launch — Islands, view transitions, deployment
You can stop at any point and pick up later. Ready?2. Get to Know the User
Astro attracts very different people — a React developer learns differently than someone building their first website. Have a quick conversation:
Quick question before we start — what's your background?
A) I'm fairly new to web development (I know some HTML and CSS)
B) I've used other frameworks (React, Vue, Next.js...)
C) I've worked with Astro before and want to explore newer features
This helps me adjust the pace and skip things you already know.How to adapt:
- A — New to web dev: Take your time with HTML/CSS concepts. Explain npm, the terminal, imports. Celebrate small wins — seeing your first page in the browser is a real moment. Don't assume any prior framework knowledge.
- B — Knows other frameworks: Draw comparisons: "In React everything ships JS; in Astro, nothing does unless you ask." Focus on what makes Astro's mental model different. You can move faster through markup basics.
- C — Knows Astro: Ask what they want to learn. Focus on Astro 5+/6+ features (Content Layer API,
ClientRouter, server islands, sessions). Lesson 1 can be compressed if they already have a project.
Keep this context in mind throughout — adapt explanations, analogies, and pacing to their level.
3. Quick Setup Check
Keep it lightweight:
Before we start building, let's make sure you have:
- Node.js 18+ (run `node -v` to check)
- A code editor (VS Code + the Astro extension is great, but anything works)
- A terminal you're comfortable with
All good? Let's go!If something's missing, help them fix it. Don't make it feel like a blocker.
Learning Flow
The tutorial follows a Discover → Build → Reflect rhythm for each part:
Discover
Introduce the concept with context: what is it, why does it exist, how does it fit into Astro's approach to the web? Use ASCII diagrams when they help. Keep it brief — just enough to understand what we're about to build.
Build
Build it together. Guide the user through code step by step, explaining decisions as you go. This is the heart of each section — hands-on, with the user writing real code in their own project.
Reflect
After building, pause to make sure it clicked:
- Ask a few questions to check understanding (not quiz-style — more like "so why do you think Astro does it this way?")
- Review the code together
- Test it in the browser
- If errors come up, explore them — they're part of learning
This is lighter than a formal verification gate. The goal is understanding, not passing a test. But don't move forward if something fundamental isn't clear.
Three-Lesson Structure
Lesson 1: Your First Astro Site (45 min)
Goal: Create the blog's foundation — pages, components, layouts, and styling.
Parts: 1. Create project, explore file structure, build first pages
- Load
guides/01-first-astro-site.md - Reflect:
reflect/project-setup.md
2. Create Navigation, Card (Props), BaseLayout (Slots), refactor pages
- Reflect:
reflect/components-layouts.md
3. Add scoped CSS, global styles, class:list, CSS variables
- Reflect:
reflect/styling.md
Concepts (load when explaining): concepts/component-model.md, concepts/file-based-routing.md
Lesson 2: Content & Dynamic Routes (45 min)
Goal: Add blog posts using Content Collections with type-safe schemas and dynamic routing.
Parts: 1. Create content collection, Zod schema, sample posts
- Load
guides/02-content-dynamic-routes.md - Reflect:
reflect/content-collections.md
2. Create dynamic routes with getStaticPaths() and render()
- Reflect:
reflect/dynamic-routes.md
3. Build blog index with sorting, draft filtering, pagination, RSS feed
- Reflect:
reflect/blog-index.md
Concepts: concepts/content-layer.md, concepts/file-based-routing.md
Lesson 3: Interactivity & Launch (45 min)
Goal: Add interactive islands, smooth transitions, and deploy the site.
Parts: 1. Install Preact, create interactive components, understand client directives
- Load
guides/03-interactivity-production.md - Reflect:
reflect/islands.md
2. Add ClientRouter for view transitions, transition animations
- Reflect:
reflect/view-transitions.md
3. Build for production, explore output, deploy
- Reflect:
reflect/deploy.md
Concepts: concepts/islands-architecture.md
When Things Go Wrong
Errors are a normal part of building anything. When they come up:
1. Don't brush past them. Resist the temptation to say "we'll fix this later." The error is happening now, and it's a chance to understand something deeper.
2. Explore together. Ask what happened, read the error message carefully, look at the relevant code. Load help/common-errors.md to see if it's a known issue.
3. Explain the "why." "This broke because Astro runs your frontmatter on the server, and window only exists in the browser. So when you wrote window.innerWidth in the frontmatter..." — that kind of explanation sticks.
4. Fix it and move on. Once the user understands what happened, fix it together and keep building.
Common Error Areas
- Setup: Node version, port conflicts, dependency issues
- Components: Browser APIs in frontmatter, Props/Slots wiring, imports
- Content Collections: Config file path (
src/content.config.tsnotsrc/content/config.ts), Zod imports, render API - Routing: Missing
getStaticPaths(), dynamic route params - Islands: Missing client directives, framework not installed
- Build: SSR APIs used in static pages, missing adapters
Answering Questions Beyond the Tutorial
When the user asks something the tutorial doesn't cover:
1. Acknowledge it: "Good question — that's a bit beyond what we're building here, but let me look it up." 2. Use mcp__astro-docs__search_astro_docs to search the official Astro docs. 3. Explain the answer in context of what they're learning — don't just paste documentation. 4. Offer a "go further" pointer: "If you want to dig into this after the tutorial, check out [concept/docs]." 5. Get back to building — keep the momentum going.
Using the astro-framework Skill
When implementing Astro code, load the relevant reference for accuracy:
| Topic | Reference | Rule |
|---|---|---|
| Components, Props, Slots | references/components.md | rules/astro-components.rule.md |
| Styling | references/styling.md | — |
| Content Collections | references/content-collections.md | rules/content-collections.rule.md |
| Routing & Dynamic Routes | references/routing.md | rules/astro-routing.rule.md |
| Client Directives | references/client-directives.md | rules/client-hydration.rule.md |
| View Transitions | references/view-transitions.md | — |
| SSR & Adapters | references/ssr-adapters.md | rules/astro-ssr.rule.md |
| Images | references/images.md | rules/astro-images.rule.md |
| TypeScript | — | rules/astro-typescript.rule.md |
All references are under skills/astro-framework/.
Pacing & Progress
Between Lessons
After finishing a lesson, suggest a commit and check in:
Nice work! Let's save this:
git add .
git commit -m "Lesson [N]: [what we built]"
Want to keep going with Lesson [N+1], or take a break?Resuming a Session
If the user returns to continue:
- Look at their existing files to figure out where they left off
- Give a quick recap of what they've built so far
- Pick up where they stopped
If Someone Wants to Skip Ahead
Each lesson builds on the previous one, so skipping can cause problems. But if they want to:
- Ask them to show their current project
- Assess what's already in place
- If the foundations are there, let them jump ahead
- If not, explain what's missing and why it matters
If Someone's Struggling
Slow down. Break the current step into smaller pieces. Add more explanation. Ask what specifically feels confusing. Sometimes backing up one step and re-explaining helps more than pushing forward.
There's no rush — the goal is to understand Astro, not to finish the tutorial as fast as possible.
Guiding Principles
1. Build to understand: Get something working, then explain why it works. Momentum builds confidence. 2. Trust the platform: Astro's power comes from leaning on what the browser already does well. Reinforce this throughout. 3. Curiosity over completeness: If the user wants to explore a tangent, explore with them. The tutorial is a guide, not a script. 4. Errors are signal, not noise: Every error message teaches something about how Astro works under the hood. 5. Adapt to the person: A beginner needs encouragement and explanation. An experienced dev needs "here's what's different." Read the room. 6. Ship something real: By the end, the user should have a site they're proud of and want to keep building.
Learn Astro Cheatsheet
Version: 1.0.0 | Astro 5.x | Updated: 2026-04-01 | Author: webreactiva.com
---
Quick Reference Card
| Topic | Rule of Thumb |
|---|---|
| Role | Be a co-pilot, not a lecturer; build with the user, do not dump theory first |
| Flow | Discover -> Build -> Reflect for every part |
| Project | Personal blog that grows across 3 lessons |
| Lesson 1 | Pages, components, layouts, scoped styling |
| Lesson 2 | Content Collections, schemas, dynamic routes, blog index |
| Lesson 3 | Islands, view transitions, production build, deploy |
| Adaptation | Beginner: explain web basics; framework dev: compare mental models; Astro user: focus on Astro 5+ features |
| Code Accuracy | Load targeted docs from skills/astro-framework/ before implementing Astro-specific code |
| When blocked | Read the error, explain why it happens, fix it with the user, then continue |
| Tutorial posture | Keep momentum, but do not move on if a core concept is still unclear |
---
Decision Trees
Where to Start
User wants to learn Astro from scratch?
├─ Yes ──> Start with welcome + background check + setup check
└─ No ──> This skill may not be the right fit; use focused Astro references insteadPace Selection
What's the user's background?
├─ New to web dev ──> explain HTML/CSS/npm/imports; slower pace
├─ Used React/Vue/Next ──> compare with Astro's zero-JS default; faster pace
└─ Used Astro before ──> ask goals; compress basics; emphasize Astro 5+/6+ changesWhat to Load Next
Need concept framing? ──> load `concepts/*.md`
Need hands-on steps? ──> load `guides/*.md`
Need understanding check? ──> load `reflect/*.md`
Hit an error? ──> load `help/common-errors.md`
Need official answer beyond tutorial? ──> use Astro docs search
Need implementation accuracy? ──> load matching `skills/astro-framework/` reference/ruleSession Progress
New learner? ──> Lesson 1
Lesson 1 complete? ──> Lesson 2
Lesson 2 complete? ──> Lesson 3
Returning learner? ──> inspect files, recap progress, resume from last stable step
Wants to skip ahead? ──> inspect project first, then decide if foundations are sufficient---
Critical "NEVER Do" List
- NEVER turn the tutorial into a passive wall of explanation before building
- NEVER assume the user's background; ask first and adapt pace
- NEVER move to the next part if the current concept is fundamentally unclear
- NEVER hand-wave errors away; use them to teach how Astro works
- NEVER answer advanced Astro questions from memory when a targeted reference or doc lookup is needed
- NEVER teach React-style default hydration as if it were normal in Astro
- NEVER skip the "why" behind file-based routing, content collections, or islands
- NEVER overload beginners with framework jargon they do not need yet
- NEVER let experienced users get stuck in basics if they are clearly ready for Astro 5+ topics
- NEVER lose the project thread; every explanation should connect back to the blog being built
---
Three-Lesson Map
Lesson 1: First Astro Site
- Goal: create the blog foundation with pages, components, layouts, and styling
- Guide:
guides/01-first-astro-site.md - Concepts:
concepts/component-model.md,concepts/file-based-routing.md - Reflection:
reflect/project-setup.md,reflect/components-layouts.md,reflect/styling.md
Lesson 2: Content & Dynamic Routes
- Goal: add blog posts with Content Collections, schemas, and dynamic routing
- Guide:
guides/02-content-dynamic-routes.md - Concepts:
concepts/content-layer.md,concepts/file-based-routing.md - Reflection:
reflect/content-collections.md,reflect/dynamic-routes.md,reflect/blog-index.md
Lesson 3: Interactivity & Launch
- Goal: add islands, view transitions, and production deployment
- Guide:
guides/03-interactivity-production.md - Concepts:
concepts/islands-architecture.md - Reflection:
reflect/islands.md,reflect/view-transitions.md,reflect/deploy.md
Teaching Patterns (Brief)
- Discover: explain what the concept is, why Astro has it, and where it fits
- Build: write real code in the learner's project, step by step
- Reflect: ask short understanding questions, review code, test in browser, clear confusion
- Resume: inspect the project, recap progress, continue from the last completed part
- Checkpoint: suggest a commit after each lesson
Common Trouble Areas
- Setup: wrong Node version, dependency install issues, dev server conflicts
- Components: confusion around Props, Slots, imports, and server-only frontmatter
- Content Collections: wrong config path, wrong Zod import, wrong render API
- Routing: missing
getStaticPaths(), unclear dynamic params, broken links - Islands: missing framework install, missing
client:*directive, misunderstanding hydration - Production: SSR/static mismatch, adapter confusion, build-only errors
References
Tutorial material lives in skills/learning-astro/:
concepts/component-model.md concepts/content-layer.md concepts/file-based-routing.md concepts/islands-architecture.md
guides/01-first-astro-site.md guides/02-content-dynamic-routes.md guides/03-interactivity-production.md
reflect/blog-index.md reflect/components-layouts.md reflect/content-collections.md reflect/deploy.md reflect/dynamic-routes.md reflect/islands.md reflect/project-setup.md reflect/styling.md reflect/view-transitions.md
help/common-errors.md help/verification-guide.md
Astro implementation references live in skills/astro-framework/ when exact framework behavior matters.
Astro Component Model
Anatomy of an Astro Component
Every .astro file has up to three sections:
┌─────────────────────────────────────────┐
│ --- (frontmatter fence) │
│ // Server-side JavaScript/TypeScript │
│ // Runs at build time (or request time │
│ // in SSR). Top-level await allowed. │
│ // No browser APIs here. │
│ --- │
├─────────────────────────────────────────┤
│ <!-- Template (HTML output) --> │
│ Uses {expressions} for dynamic content │
│ Supports components, slots, directives │
│ Uses `class` not `className` │
├─────────────────────────────────────────┤
│ <style> │
│ /* Scoped CSS by default */ │
│ /* Only affects THIS component */ │
│ </style> │
└─────────────────────────────────────────┘Why This Model Matters
The frontmatter/template separation is the foundation of Astro's performance story:
Traditional SPA (React/Next):
Server renders HTML → Browser downloads JS bundle → JS re-renders everything
Result: Every component ships JavaScript
Astro:
Frontmatter runs on server → HTML generated → Sent to browser. Done.
Result: Zero JavaScript by defaultThe key insight: most website content doesn't need interactivity. A blog post, a navigation bar, a product description — these are static. By running all the "logic" at build time in the frontmatter, Astro generates pure HTML with no runtime cost.
Frontmatter: Server Code
The code between --- fences runs on the server (at build time for static sites, at request time for SSR pages):
---
// Imports (components, utilities, data)
import BaseLayout from '../layouts/BaseLayout.astro';
import Card from '../components/Card.astro';
// Data fetching (top-level await works!)
const response = await fetch('https://api.example.com/posts');
const posts = await response.json();
// Computation
const sortedPosts = posts.sort((a, b) => b.date - a.date);
const pageTitle = "My Blog";
// Props (in components)
const { title, description } = Astro.props;
---What you CAN do in frontmatter:
- Import components and modules
- Fetch data (top-level
await) - Access
Astro.props,Astro.url,Astro.request(SSR) - Run any Node.js-compatible code
- Read environment variables
What you CANNOT do in frontmatter:
- Access
window,document,localStorage(browser APIs) - Use React hooks (
useState,useEffect) - Add event listeners directly
Template: HTML Output
The template section generates HTML using expressions:
---
const items = ['Astro', 'React', 'Vue'];
const showBanner = true;
---
<!-- Simple expressions -->
<h1>{pageTitle}</h1>
<!-- Conditionals -->
{showBanner && <div class="banner">Welcome!</div>}
<!-- Iteration -->
<ul>
{items.map(item => <li>{item}</li>)}
</ul>
<!-- Components -->
<Card title="My Card" />Key difference from JSX:
- Use
classnotclassName - Use
fornothtmlFor - Boolean attributes work naturally:
<input disabled /> - No need to import
React
Props: Passing Data to Components
---
// Define the shape of accepted props
interface Props {
title: string;
description?: string; // Optional
tags?: string[]; // Optional with type
}
// Destructure with defaults
const { title, description = "No description", tags = [] } = Astro.props;
---
<article>
<h2>{title}</h2>
<p>{description}</p>
{tags.length > 0 && (
<div class="tags">
{tags.map(tag => <span class="tag">{tag}</span>)}
</div>
)}
</article>Slots: Composing Components
Slots allow parent components to inject content into children:
<!-- BaseLayout.astro -->
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<html>
<head>
<title>{title}</title>
<slot name="head" /> <!-- Named slot for extra head content -->
</head>
<body>
<nav>...</nav>
<main>
<slot /> <!-- Default slot: main content goes here -->
</main>
<footer>...</footer>
</body>
</html><!-- Using the layout -->
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About Me">
<link slot="head" rel="stylesheet" href="/about.css" />
<h1>About Me</h1>
<p>This goes into the default slot.</p>
</BaseLayout>Conditional slots — check if a slot was provided:
---
const hasSidebar = Astro.slots.has('sidebar');
---
<div class={hasSidebar ? 'with-sidebar' : 'full-width'}>
<main><slot /></main>
{hasSidebar && (
<aside><slot name="sidebar" /></aside>
)}
</div>When to Use .astro vs .tsx/.jsx
.astro files (majority of your site — 90%+)
├── Static content: pages, layouts, cards, headers, footers
├── Server-side data fetching
├── Composition with slots
└── Zero JavaScript shipped
.tsx/.jsx files (islands — only when needed)
├── Interactive components: forms, counters, toggles, search
├── State management (useState, useEffect, signals)
├── Event handlers that modify the UI
└── Requires a client: directive to hydrateThe rule of thumb: start with .astro. Only create a .tsx file when you need client-side interactivity that can't be achieved with a simple <script> tag.
Content Layer
What is the Content Layer?
The Content Layer is Astro's system for loading, validating, and querying structured content. It transforms raw files (Markdown, JSON, etc.) into type-safe, queryable data.
┌──────────────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ Content Source │───▶│ Loader │───▶│ Zod Schema │───▶│ Type-safe │
│ (md, json, API) │ │ (glob/file) │ │ (validation) │ │ Data Store │
└──────────────────┘ └──────────────┘ └───────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ Your Pages │
│ getCollection()
│ render() │
└──────────────┘Why Use Content Collections?
Without collections, you'd manually import files, parse frontmatter, and hope your data is consistent. Content collections give you:
1. Schema validation: A typo in your blog post date? Astro catches it at build time with a clear error message. 2. Type safety: Your editor knows exactly what fields exist on a blog post. Autocomplete works. 3. Performance: Astro optimizes data loading and caching behind the scenes. 4. Consistency: All entries in a collection share the same shape. No surprises.
Configuration (Astro 5+)
Collections are defined in src/content.config.ts — note the file location (NOT src/content/config.ts, which was the Astro 4 pattern):
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod'; // Import Zod from astro/zod, NOT from 'zod'
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(), // coerce: converts string "2024-01-15" to Date
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };Key Details
- `z.coerce.date()`: Frontmatter dates come as strings.
coerceconverts them to Date objects automatically. Usingz.date()would fail. - `z.array(z.string()).default([])`: Makes tags optional with a default empty array.
- `z.boolean().default(false)`: Draft posts default to published.
- Import `z` from `'astro/zod'`: Not from the
zodpackage directly. Astro ships its own Zod version for compatibility.
Built-in Loaders
glob() — Multiple local files
Loads entries from a directory of files. Each file becomes one entry. The file name (minus extension) becomes the entry's id.
import { glob } from 'astro/loaders';
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' })file() — Single data file
Loads entries from a single JSON or YAML file. Each top-level item becomes one entry.
import { file } from 'astro/loaders';
loader: file('src/data/authors.json')Querying Content
Get all entries
import { getCollection } from 'astro:content';
// All blog posts
const allPosts = await getCollection('blog');
// Filtered: only published posts, sorted by date
const publishedPosts = await getCollection('blog', ({ data }) => !data.draft);
const sorted = publishedPosts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);Get a single entry
import { getEntry } from 'astro:content';
const post = await getEntry('blog', 'my-first-post');Rendering Content
In Astro 5+, rendering uses a top-level render() function (not entry.render()):
import { render } from 'astro:content';
const post = await getEntry('blog', 'my-first-post');
const { Content, headings } = await render(post);Then in the template:
<article>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString()}</time>
<Content />
</article>Common Gotchas
1. Wrong config path: Use src/content.config.ts, not src/content/config.ts 2. Wrong Zod import: Use import { z } from 'astro/zod', not from 'zod' 3. Legacy `entry.render()`: In Astro 5+, use import { render } from 'astro:content' then render(entry) 4. Missing loader: Astro 5+ requires a loader property — there's no implicit file loading 5. Date validation: Use z.coerce.date() for frontmatter dates, not z.date()
File-Based Routing
How It Works
Astro maps files in src/pages/ directly to URLs:
src/pages/
├── index.astro → /
├── about.astro → /about
├── blog/
│ ├── index.astro → /blog
│ └── [...slug].astro → /blog/my-first-post (dynamic)
├── rss.xml.ts → /rss.xml (API endpoint)
└── 404.astro → /404 (custom error page)The mapping is straightforward: the file path becomes the URL path. Astro strips the file extension and converts index files to directory roots.
Static Routes
Every .astro, .md, or .mdx file in src/pages/ becomes a page:
<!-- src/pages/about.astro → /about -->
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About">
<h1>About Me</h1>
</BaseLayout>Dynamic Routes
For pages generated from data (like blog posts), use bracket syntax:
Single Parameter
<!-- src/pages/blog/[slug].astro → /blog/my-post -->
---
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
---
<h1>{post.data.title}</h1>Rest Parameters
<!-- src/pages/blog/[...slug].astro → /blog/2024/my-post -->The ... captures the full remaining path, including nested segments.
Why getStaticPaths() is Required
In static mode (default), Astro needs to know ALL possible URLs at build time to generate HTML files. getStaticPaths() tells Astro: "Here are all the pages you need to create."
Build time:
getStaticPaths() returns 50 posts
→ Astro generates 50 HTML files
→ Each lives at /blog/[slug]/index.html
→ Pure static files, no server neededIn hybrid or server mode, dynamic routes without getStaticPaths() are rendered on-demand per request.
API Endpoints
.ts or .js files in src/pages/ that export HTTP method handlers become API endpoints:
// src/pages/rss.xml.ts → /rss.xml
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: 'My Blog',
description: 'A blog about learning Astro',
site: context.site,
items: posts.map(post => ({
title: post.data.title,
pubDate: post.data.pubDate,
link: `/blog/${post.id}/`,
})),
});
}Pagination
Astro provides built-in pagination through paginate():
<!-- src/pages/blog/[...page].astro -->
---
import { getCollection } from 'astro:content';
export async function getStaticPaths({ paginate }) {
const posts = await getCollection('blog');
const sorted = posts.sort((a, b) => b.data.pubDate - a.data.pubDate);
return paginate(sorted, { pageSize: 5 });
}
const { page } = Astro.props;
// page.data = posts for this page
// page.url.prev / page.url.next = navigation URLs
// page.currentPage, page.lastPage = page numbers
---
{page.data.map(post => <PostCard post={post} />)}
<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>This generates:
/blog→ page 1/blog/2→ page 2/blog/3→ page 3
Route Priority
When multiple files could match a URL, Astro uses this priority: 1. Static routes (/about.astro) — highest priority 2. Dynamic routes with named params (/blog/[slug].astro) 3. Rest parameters (/blog/[...slug].astro) — lowest priority
Islands Architecture
The Core Idea
Islands architecture means your page is an ocean of static HTML with small islands of interactivity where needed.
┌─────────────────────────────────────────────────────────────┐
│ Static HTML (ocean) │
│ ┌──────────┐ │
│ │ Header │ ← Pure HTML, zero JS │
│ └──────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Blog Post Content │ ← Markdown → HTML│
│ │ Pure text, images, formatting │ zero JS │
│ └──────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ ┌──────────────┐ │
│ │ 🏝️ Like Button │ │ 🏝️ Search │ ← Interactive │
│ │ (client:load) │ │ (client:idle) │ islands with JS │
│ └─────────────────┘ └──────────────┘ │
│ │
│ ┌──────────┐ │
│ │ Footer │ ← Pure HTML, zero JS │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────┘Why Zero JavaScript by Default?
Consider a typical blog page:
- Header: Static navigation links. No JS needed.
- Blog content: Rendered from Markdown. No JS needed.
- Sidebar: Static list of categories. No JS needed.
- Like button: Needs to track clicks. JS needed (island).
- Footer: Static links. No JS needed.
In a traditional SPA framework (React, Next.js, SvelteKit), everything ships JavaScript — even the static parts. The browser downloads a JS bundle, parses it, and re-renders the entire page.
In Astro, only the Like button ships JavaScript. Everything else is pure HTML. The result:
Traditional SPA: 200KB+ JavaScript bundle
Astro blog page: 3KB JavaScript (just the island)This directly impacts:
- Page load speed: Less JS = faster load
- Core Web Vitals: Better LCP, FID, CLS scores
- Mobile performance: Especially on low-powered devices
- Battery life: Less CPU work = less battery drain
The Decision Tree
When adding a component, ask yourself:
Does this component need client-side interactivity?
│
├── NO → Use .astro component (zero JS)
│ Examples: headers, footers, cards, blog content, image galleries
│
└── YES → Does the user interact with it immediately?
│
├── YES → client:load
│ Examples: navigation menu, login form, hero carousel
│
└── NO → Is it below the fold (not initially visible)?
│
├── YES → client:visible
│ Examples: comments section, newsletter signup
│
└── NO → client:idle
Examples: sidebar widget, search, feedback formSpecial Cases
- `client:media`: Hydrate only when a media query matches. Perfect for a chart that's only useful on desktop:
client:media="(min-width: 768px)" - `client:only="preact"`: Skip server rendering entirely. Use when the component can't render on the server (e.g., uses
windowduring initialization).
How Hydration Works
Server / Build time:
1. Astro runs the component's render function
2. Generates static HTML
3. Marks islands with hydration metadata
Browser:
1. Receives pure HTML (renders instantly — no JS needed)
2. Loads JS only for marked islands
3. Islands "hydrate" — attach event listeners, become interactive
4. Static content remains untouchedThe key insight: the page is immediately visible as HTML. JavaScript loads afterward only for the parts that need it. This is why Astro sites feel so fast — the user sees content before any JS executes.
Framework Agnostic
One of Astro's unique features: different islands can use different frameworks on the same page:
---
import ReactCounter from './ReactCounter';
import SvelteToggle from './SvelteToggle.svelte';
import VueWidget from './VueWidget.vue';
---
<ReactCounter client:load />
<SvelteToggle client:visible />
<VueWidget client:idle />Each framework's JS is loaded independently, only for its island. There's no shared runtime overhead.
Comparison
| Approach | JavaScript Shipped | When to Use |
|---|---|---|
| SPA (React, Vue) | Everything | Highly interactive apps (dashboards, editors) |
| MPA (plain HTML) | Nothing | Pure static sites with zero interactivity |
| Islands (Astro) | Only interactive parts | Content sites with some interactivity |
Anti-Patterns to Avoid
1. `client:load` on everything — Defeats the purpose. If every component ships JS, just use a SPA. 2. Hydrating navigation for a toggle — A mobile menu toggle is a <script> with 5 lines of vanilla JS. Don't hydrate an entire React component for it. 3. Large framework bundles via `client:load` — If a component's bundle is >50KB, consider: can the static part be an Astro component, with only the interactive bit as an island? 4. `client:visible` on above-the-fold — It's already visible, so IntersectionObserver fires immediately. Use client:load directly.
Lesson 1: Your First Astro Site
Time: ~45 minutes Goal: Create the foundation of your personal blog with pages, components, layouts, and styling.
By the end of this lesson, you'll have a multi-page site with reusable components, a shared layout, and scoped styles — all shipping zero JavaScript.
---
Part 1: Create Project & First Pages (~15 min)
Architecture Overview
Before writing code, load concepts/file-based-routing.md and briefly explain how Astro maps files to URLs.
Key concept: Every file in src/pages/ becomes a page on your site. index.astro → /, about.astro → /about. No router configuration needed.
Step 1: Create the project
Guide the user through the CLI:
npm create astro@latest my-blogWalk through each prompt:
- Where should we create your project? →
my-blog(or their choice) - How would you like to start? → "Empty" (we'll build everything from scratch)
- Install dependencies? → Yes
- Initialize a git repository? → Yes
- TypeScript? → Yes, strict
Then enter the project:
cd my-blogStep 2: Explore the file structure
my-blog/
├── astro.config.mjs ← Astro configuration
├── tsconfig.json ← TypeScript config
├── package.json
├── src/
│ └── pages/
│ └── index.astro ← Home page (/)
└── public/ ← Static assets (images, fonts, favicon)Explain each directory's purpose:
src/pages/— Every file here becomes a URL on your sitesrc/— All your source code lives here (components, layouts, styles)public/— Files served as-is (not processed by Astro)astro.config.mjs— Project configuration
Step 3: Start the dev server
npm run devOpen http://localhost:4321/ in the browser. They should see the default welcome page.
Step 4: Edit the home page
Open src/pages/index.astro and replace its content:
---
const pageTitle = "My Blog";
const name = "<!-- user's name -->";
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{pageTitle}</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<h1>{pageTitle}</h1>
<p>Welcome to {name}'s blog. This is where I'll share what I'm learning.</p>
</body>
</html>Teaching moment: Point out the frontmatter (--- fences). This is server-side JavaScript — it runs at build time, not in the browser. The {pageTitle} syntax injects variables into the HTML.
Step 5: Create the About page
Create src/pages/about.astro:
---
const pageTitle = "About Me";
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{pageTitle}</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<h1>{pageTitle}</h1>
<p>I'm learning Astro and building this blog to document my journey.</p>
</body>
</html>Step 6: Verify
- Visit
http://localhost:4321/— home page works - Visit
http://localhost:4321/about— about page works - Click navigation links — they switch between pages
Reflect: Load reflect/project-setup.md
---
Part 2: Components, Props, Slots & Layouts (~15 min)
Architecture Overview
Load concepts/component-model.md and explain the three-section anatomy of an Astro component.
Key concept: Components let you reuse HTML. Layouts are components that wrap entire pages with shared structure (head, nav, footer).
Problem to solve: The navigation and HTML boilerplate is duplicated in both pages. Let's fix that.
Step 1: Create Navigation component
Create src/components/Navigation.astro:
---
// No props needed yet — this component is self-contained
---
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
</nav>Step 2: Create a Card component (introduces Props)
Create src/components/Card.astro:
---
interface Props {
title: string;
body: string;
href?: string;
}
const { title, body, href } = Astro.props;
---
<article class="card">
<h3>
{href ? <a href={href}>{title}</a> : title}
</h3>
<p>{body}</p>
</article>Teaching moment: Explain interface Props — it defines what data the component accepts. href? means it's optional. Astro.props is how you access the values passed by the parent.
Step 3: Create BaseLayout (introduces Slots)
Create src/layouts/BaseLayout.astro:
---
import Navigation from '../components/Navigation.astro';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
</head>
<body>
<Navigation />
<main>
<slot />
</main>
<footer>
<p>© {new Date().getFullYear()} My Blog</p>
</footer>
</body>
</html>Teaching moment: <slot /> is where child content goes. When a page uses this layout, everything the page writes goes into that slot. The navigation and footer are shared automatically.
Step 4: Refactor pages to use the layout
Update src/pages/index.astro:
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Card from '../components/Card.astro';
---
<BaseLayout title="My Blog">
<h1>My Blog</h1>
<p>Welcome! Here's what I'm learning.</p>
<Card
title="What is Astro?"
body="Astro is a web framework for building fast, content-driven websites."
href="https://astro.build"
/>
<Card
title="Why Astro?"
body="Zero JavaScript by default. Your site is fast because it ships less code."
/>
</BaseLayout>Update src/pages/about.astro:
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About Me">
<h1>About Me</h1>
<p>I'm learning Astro and building this blog to document my journey.</p>
</BaseLayout>Teaching moment: Look how much cleaner this is! No duplicated HTML, no duplicated navigation. The layout handles all the boilerplate.
Step 5: Verify
- Both pages still work and look the same
- Navigation appears on both pages (from the layout)
- Footer appears on both pages
- Card component renders with different props
Reflect: Load reflect/components-layouts.md
---
Part 3: Styling (~15 min)
Step 1: Scoped CSS
Add styles to Card.astro:
<!-- Add after the template -->
<style>
.card {
padding: 1.5rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
margin-bottom: 1rem;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h3 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
h3 a {
color: #4f46e5;
text-decoration: none;
}
p {
margin: 0;
color: #64748b;
}
</style>Teaching moment: These styles are scoped — they ONLY affect this component. The h3 style won't change any other h3 on the site. Astro achieves this by adding unique class hashes to elements. Inspect in DevTools to see it!
Step 2: Global styles
Create src/styles/global.css:
/* Reset and base styles */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-text: #1e293b;
--color-text-light: #64748b;
--color-primary: #4f46e5;
--color-bg: #ffffff;
--color-bg-alt: #f8fafc;
--font-body: system-ui, -apple-system, sans-serif;
--font-mono: 'Courier New', monospace;
--max-width: 48rem;
}
html {
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-bg);
}
body {
max-width: var(--max-width);
margin: 0 auto;
padding: 1rem 1.5rem;
line-height: 1.6;
}
a {
color: var(--color-primary);
}Import it in BaseLayout.astro frontmatter:
---
import Navigation from '../components/Navigation.astro';
import '../styles/global.css';
interface Props {
title: string;
}
const { title } = Astro.props;
---Teaching moment: Importing a CSS file in the frontmatter applies it globally. CSS variables (custom properties) defined in :root are accessible everywhere.
Step 3: Style the Navigation
Add styles to Navigation.astro:
<style>
nav {
display: flex;
gap: 1.5rem;
padding: 1rem 0;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 2rem;
}
a {
color: var(--color-text);
text-decoration: none;
font-weight: 500;
}
a:hover {
color: var(--color-primary);
}
</style>Step 4: Introduce class:list
Show the class:list directive for conditional classes. Add to a component:
---
interface Props {
title: string;
body: string;
href?: string;
featured?: boolean;
}
const { title, body, href, featured = false } = Astro.props;
---
<article class:list={['card', { featured }]}>
<!-- ... -->
</article>
<style>
.card { /* base styles */ }
.featured {
border-color: var(--color-primary);
background: var(--color-bg-alt);
}
</style>Teaching moment: class:list accepts an array. Strings are always included. Objects include the key as a class only when the value is truthy. So { featured } adds the class featured only when the prop is true.
Step 5: Verify
- Styles look polished and consistent
- Card hover effects work
- Navigation is styled
- Inspect element: see Astro's scoped class hashes
- Confirm: Card styles don't affect other components
Reflect: Load reflect/styling.md
---
Lesson 1 Complete!
What you built:
- A multi-page Astro site with file-based routing
- Reusable components with Props (Card) and Slots (BaseLayout)
- A shared layout with Navigation and Footer
- Scoped and global CSS with CSS variables
What you learned:
- Astro component anatomy: frontmatter → template → styles
- File-based routing: files in
src/pages/become URLs - Props and Slots for component composition
- Scoped CSS keeps styles isolated by default
Git commit:
git add .
git commit -m "Lesson 1: First Astro site with pages, components, layouts, and styling"Ready for Lesson 2? We'll add blog posts using Content Collections — Astro's type-safe content management system.
Lesson 2: Content & Dynamic Routes
Time: ~45 minutes Goal: Add blog posts to your site using Content Collections with type-safe schemas and dynamic routing. Prerequisites: Completed Lesson 1 (pages, components, layout, styling)
By the end of this lesson, your blog will have real posts with validated frontmatter, individual post pages generated from dynamic routes, a sorted blog index, and an RSS feed.
---
Part 1: Content Collections (~15 min)
Architecture Overview
Load concepts/content-layer.md and explain the Content Layer pipeline:
Markdown files → glob loader → Zod schema validation → Type-safe data → Your pagesKey concept: Content Collections give you type-safe content management. Define a schema once, and Astro validates every blog post at build time. A missing title or invalid date? You'll know immediately — not after deploying.
Step 1: Create blog posts
Create the content directory and 3 sample posts:
Create src/content/blog/first-post.md:
---
title: "My First Blog Post"
description: "I just started learning Astro and I'm already loving it."
pubDate: 2024-01-15
tags: ["astro", "learning"]
---
# Getting Started with Astro
Today I started learning Astro. What immediately stood out to me is that it ships **zero JavaScript** by default. That means my blog is incredibly fast right from the start.
## What I've Learned So Far
- Astro uses file-based routing
- Components have a frontmatter section for server-side code
- Styles are scoped by default
- Layouts use slots for composability
I'm excited to keep learning!Create src/content/blog/second-post.md:
---
title: "Understanding Astro Components"
description: "A deep dive into how Astro components work."
pubDate: 2024-02-01
tags: ["astro", "components"]
---
# How Astro Components Work
Astro components are divided into three sections:
1. **Frontmatter** — Server-side JavaScript between `---` fences
2. **Template** — HTML with expressions like `{variable}`
3. **Styles** — Scoped CSS that only affects this component
The key insight is that the frontmatter runs at **build time**, not in the browser. This is why Astro sites are so fast — there's no JavaScript to download and execute.Create src/content/blog/third-post.md:
---
title: "Building Layouts and Reusable Components"
description: "How to use Props and Slots to build reusable Astro components."
pubDate: 2024-03-10
tags: ["astro", "components", "layouts"]
draft: false
---
# Layouts and Components
One of the best things about Astro is how clean the component model is.
## Props for Data
Components accept props through an `interface Props` definition. This gives you TypeScript autocomplete and type checking — catch errors before they hit production.
## Slots for Composition
Layouts use `<slot />` to define where child content goes. This is similar to React's `children` prop but feels more natural in HTML.Step 2: Define the content collection
Create src/content.config.ts:
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };Teaching moment — explain each part:
- `glob` loader: Reads all Markdown files from
./src/content/blog. Each file becomes one entry. - `z.coerce.date()`: Frontmatter dates are strings like
"2024-01-15".coerceautomatically converts them to JavaScript Date objects. - `z.array(z.string()).default([])`: Tags are optional — if missing, defaults to an empty array.
- `z.boolean().default(false)`: The
draftfield defaults tofalse(published). - `export const collections`: This registers the collection with Astro. The key
blogis the name you'll use ingetCollection('blog').
Important: The file is src/content.config.ts (at the src/ root), NOT src/content/config.ts. This is the Astro 5+ convention.
Step 3: Show type safety in action
Temporarily break a post to demonstrate validation. Remove the title from one post's frontmatter, then run:
npm run buildAstro will show a clear error like:
[ERROR] blog → first-post.md: "title" is requiredTeaching moment: This is the power of schema validation — errors are caught at build time, not after deployment. Restore the title and continue.
Step 4: Use the collection in a page
Update src/pages/index.astro to list blog posts:
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Card from '../components/Card.astro';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
const sortedPosts = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<BaseLayout title="My Blog">
<h1>My Blog</h1>
<p>Welcome! Here's what I've been writing about.</p>
<section>
{sortedPosts.map(post => (
<Card
title={post.data.title}
body={post.data.description}
href={`/blog/${post.id}`}
/>
))}
</section>
</BaseLayout>Teaching moment: getCollection('blog') returns all entries, fully typed. post.data.title has autocomplete because of the Zod schema. The data lives in post.data, and post.id is the filename without extension.
Step 5: Verify
- Home page lists all 3 posts sorted by date (newest first)
- Post data comes from frontmatter (title, description)
- Links point to
/blog/[slug](will 404 until Part 2 — that's expected)
Reflect: Load reflect/content-collections.md
---
Part 2: Dynamic Routes for Blog Posts (~15 min)
Architecture Context
Briefly revisit concepts/file-based-routing.md — specifically the dynamic routes section.
Key concept: We need to create one page template that generates a unique page for every blog post. That's what dynamic routes do — [...slug].astro tells Astro: "generate a page for each entry returned by getStaticPaths()."
Step 1: Create the blog post layout
Create src/layouts/BlogPost.astro:
---
import BaseLayout from './BaseLayout.astro';
interface Props {
title: string;
pubDate: Date;
description: string;
tags: string[];
}
const { title, pubDate, description, tags } = Astro.props;
---
<BaseLayout title={title}>
<article>
<header>
<h1>{title}</h1>
<p class="description">{description}</p>
<div class="meta">
<time datetime={pubDate.toISOString()}>
{pubDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
{tags.length > 0 && (
<div class="tags">
{tags.map(tag => <span class="tag">{tag}</span>)}
</div>
)}
</div>
</header>
<hr />
<slot />
</article>
</BaseLayout>
<style>
article {
max-width: 65ch;
}
header {
margin-bottom: 2rem;
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.description {
color: var(--color-text-light);
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.meta {
display: flex;
gap: 1rem;
align-items: center;
color: var(--color-text-light);
font-size: 0.9rem;
}
.tags {
display: flex;
gap: 0.5rem;
}
.tag {
background: var(--color-bg-alt);
padding: 0.15rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.8rem;
}
hr {
border: none;
border-top: 1px solid #e2e8f0;
margin: 1.5rem 0;
}
</style>Step 2: Create the dynamic route
Create src/pages/blog/[...slug].astro:
---
import { getCollection, render } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BlogPost
title={post.data.title}
pubDate={post.data.pubDate}
description={post.data.description}
tags={post.data.tags}
>
<Content />
</BlogPost>Teaching moment — explain each part:
1. `getStaticPaths()`: Astro calls this at build time. It returns an array: one entry per blog post. Each entry has params (the URL slug) and props (the data passed to the page).
2. `params: { slug: post.id }`: post.id is the filename without extension. So first-post.md → slug: "first-post" → URL /blog/first-post.
3. `render(post)`: Converts the Markdown content into a renderable <Content /> component. This is the Astro 5+ API — imported from 'astro:content'.
4. `<Content />`: Placed inside the <slot /> of BlogPost layout, rendering the Markdown as formatted HTML.
Step 3: Verify
- Visit
/blog/first-post— the post renders with title, date, tags, and content - Visit
/blog/second-postand/blog/third-post— each works - The layout wraps each post consistently
- Links from the home page work
Reflect: Load reflect/dynamic-routes.md
---
Part 3: Blog Index, Pagination & RSS (~15 min)
Step 1: Create a dedicated blog index page
Create src/pages/blog/index.astro:
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const allPosts = await getCollection('blog', ({ data }) => !data.draft);
const sortedPosts = allPosts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<BaseLayout title="Blog">
<h1>Blog</h1>
<p>All my posts, newest first.</p>
<ul class="post-list">
{sortedPosts.map(post => (
<li>
<a href={`/blog/${post.id}`}>
<h3>{post.data.title}</h3>
<p class="description">{post.data.description}</p>
<time>
{post.data.pubDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
</a>
</li>
))}
</ul>
</BaseLayout>
<style>
.post-list {
list-style: none;
padding: 0;
}
.post-list li {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.post-list a {
text-decoration: none;
color: inherit;
display: block;
}
.post-list a:hover h3 {
color: var(--color-primary);
}
h3 {
margin: 0 0 0.25rem;
font-size: 1.2rem;
}
.description {
color: var(--color-text-light);
margin: 0 0 0.25rem;
}
time {
color: var(--color-text-light);
font-size: 0.85rem;
}
</style>Teaching moment: Notice getCollection('blog', ({ data }) => !data.draft) — the second argument is a filter function. This excludes any post with draft: true.
Step 2: Add RSS feed
Install the RSS package:
npx astro add rssCreate src/pages/rss.xml.ts:
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
export async function GET(context: APIContext) {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const sorted = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
return rss({
title: 'My Blog',
description: 'A blog about learning Astro',
site: context.site ?? 'https://example.com',
items: sorted.map(post => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/blog/${post.id}/`,
})),
});
}Teaching moment: This is an API endpoint — a .ts file that exports an HTTP handler. It generates XML, not HTML. The file path src/pages/rss.xml.ts means it's accessible at /rss.xml.
Add the site property to astro.config.mjs:
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://example.com', // Replace with actual URL when deploying
});Add the RSS link to BaseLayout.astro <head>:
<link rel="alternate" type="application/rss+xml" title="My Blog" href="/rss.xml" />Step 3: Update Navigation
Update src/components/Navigation.astro to include the blog link (it should already be there from Lesson 1, but verify):
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
<a href="/about">About</a>
</nav>Step 4: Verify
/blogshows all posts sorted by date, drafts excluded- Each post links to its individual page
/rss.xmlreturns valid XML with all posts- Navigation includes Blog link
Reflect: Load reflect/blog-index.md
---
Lesson 2 Complete!
What you built:
- Content collections with Zod schema validation
- Dynamic routes generating individual blog post pages
- A blog index with sorting and draft filtering
- An RSS feed as an API endpoint
What you learned:
- Content Layer API:
defineCollection,globloader, Zod schemas getCollection()andrender()fromastro:contentgetStaticPaths()for dynamic route generation- API endpoints in
src/pages/(.tsfiles)
Git commit:
git add .
git commit -m "Lesson 2: Content collections, dynamic routes, blog index, and RSS"Ready for Lesson 3? We'll add interactive components using Astro's islands architecture and prepare the site for deployment.
Lesson 3: Interactivity & Production
Time: ~45 minutes Goal: Add interactive components (islands), smooth page transitions, and deploy the site. Prerequisites: Completed Lessons 1-2 (pages, components, content collections, dynamic routes)
By the end of this lesson, your blog will have interactive Preact components, SPA-like navigation with view transitions, and be ready for production deployment.
---
Part 1: Islands — Interactive Components (~15 min)
Architecture Overview
Load concepts/islands-architecture.md and explain the core concept:
Your page = ocean of static HTML + islands of interactivityKey concept: Everything you've built so far ships ZERO JavaScript. Astro only sends JS to the browser when you explicitly ask for it with a client: directive. This is called "islands architecture" — most of the page is static HTML, with small interactive islands where needed.
Step 1: Install Preact
npx astro add preactThis does two things: 1. Installs @astrojs/preact and preact 2. Adds the integration to astro.config.mjs
Why Preact? It's a 3KB alternative to React with the same API. For a blog, it keeps your JS bundle tiny. The user can substitute React if they prefer — the concepts are identical.
Step 2: Create a LikeButton (demonstrates islands)
Create src/components/LikeButton.tsx:
import { useState } from 'preact/hooks';
export default function LikeButton() {
const [likes, setLikes] = useState(0);
return (
<button
onClick={() => setLikes(likes + 1)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 1rem',
border: '1px solid #e2e8f0',
borderRadius: '0.5rem',
background: likes > 0 ? '#fef2f2' : '#ffffff',
cursor: 'pointer',
fontSize: '1rem',
transition: 'all 0.2s',
}}
>
{likes > 0 ? '❤️' : '🤍'} {likes} {likes === 1 ? 'like' : 'likes'}
</button>
);
}Step 3: First — use it WITHOUT a client directive
Add to BlogPost.astro, after the <Content /> slot:
---
import LikeButton from '../components/LikeButton';
---
<!-- In the template, after <slot /> -->
<div class="post-actions">
<LikeButton />
</div>Teaching moment: Visit a blog post. The button appears! But click it — nothing happens. Inspect the page source: you'll see static HTML (<button>🤍 0 likes</button>) but NO JavaScript was loaded.
This is the Astro default: framework components render to static HTML. They look right but have no interactivity.
Step 4: Add client:load — make it interactive
Change the usage to:
<LikeButton client:load />Now click the button — it works! Open the Network tab in DevTools:
- You'll see a small JS file loaded (the Preact runtime + LikeButton code)
- This is the ONLY JavaScript on the page
- Everything else remains static HTML
Teaching moment: client:load tells Astro: "Send the JavaScript for this component to the browser and hydrate it immediately." Without the directive, the component is server-rendered only.
Step 5: Create a SearchPosts component (demonstrates client:idle)
Create src/components/SearchPosts.tsx:
import { useState } from 'preact/hooks';
interface Post {
title: string;
description: string;
slug: string;
}
interface Props {
posts: Post[];
}
export default function SearchPosts({ posts }: Props) {
const [query, setQuery] = useState('');
const filtered = posts.filter(
post =>
post.title.toLowerCase().includes(query.toLowerCase()) ||
post.description.toLowerCase().includes(query.toLowerCase())
);
return (
<div style={{ marginBottom: '2rem' }}>
<input
type="search"
placeholder="Search posts..."
value={query}
onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
style={{
width: '100%',
padding: '0.75rem 1rem',
border: '1px solid #e2e8f0',
borderRadius: '0.5rem',
fontSize: '1rem',
marginBottom: '1rem',
}}
/>
{query && (
<p style={{ color: '#64748b', fontSize: '0.9rem' }}>
{filtered.length} {filtered.length === 1 ? 'result' : 'results'} for "{query}"
</p>
)}
<ul style={{ listStyle: 'none', padding: 0 }}>
{filtered.map(post => (
<li key={post.slug} style={{ marginBottom: '1rem' }}>
<a href={`/blog/${post.slug}`} style={{ textDecoration: 'none', color: '#4f46e5' }}>
<strong>{post.title}</strong>
</a>
<p style={{ color: '#64748b', margin: '0.25rem 0 0' }}>{post.description}</p>
</li>
))}
</ul>
</div>
);
}Add to src/pages/blog/index.astro:
---
import SearchPosts from '../../components/SearchPosts';
// ... existing imports and data fetching ...
const postData = sortedPosts.map(post => ({
title: post.data.title,
description: post.data.description,
slug: post.id,
}));
---
<BaseLayout title="Blog">
<h1>Blog</h1>
<SearchPosts posts={postData} client:idle />
<!-- ... rest of the page ... -->
</BaseLayout>Teaching moment: client:idle means "hydrate this component when the browser is idle." The search isn't critical for the initial page load — the user sees the full post list first (server-rendered HTML). The search becomes interactive once the browser has finished its priority work.
Step 6: Verify
- LikeButton is interactive (clicking increments the count)
- SearchPosts filters posts as you type
- Network tab shows small JS bundles only for these components
- Remove
client:loadfrom LikeButton temporarily → button stops working (proves the point)
Reflect: Load reflect/islands.md
---
Part 2: View Transitions (~15 min)
Key Concept
View Transitions make navigation feel instant — like a SPA — without actually being one. Instead of a full page reload (white flash, all resources re-fetched), Astro fetches only the new page's HTML and smoothly swaps the content.
Step 1: Add ClientRouter
Update src/layouts/BaseLayout.astro:
---
import Navigation from '../components/Navigation.astro';
import { ClientRouter } from 'astro:transitions';
import '../styles/global.css';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
<link rel="alternate" type="application/rss+xml" title="My Blog" href="/rss.xml" />
<ClientRouter />
</head>
<!-- ... rest of layout ... -->
</html>Teaching moment: <ClientRouter /> intercepts link clicks. Instead of a full page load, it fetches the new page's HTML, then swaps the <body> content with a smooth animation. The result feels instant.
Important: In Astro 5+, this component is called ClientRouter. In Astro 4 it was called ViewTransitions. If the user sees errors about ViewTransitions, they need to update to ClientRouter.
Step 2: Test the basic transition
Click between pages. Notice:
- No white flash between pages
- The transition is smooth
- The URL changes correctly
- The browser's back/forward buttons work
Step 3: Add transition animations
Add transition:animate directives to elements in BaseLayout.astro:
<body>
<Navigation />
<main transition:animate="fade">
<slot />
</main>
<footer>
<p>© {new Date().getFullYear()} My Blog</p>
</footer>
</body>Built-in animations:
fade— fades content in/out (good default)slide— slides content left/rightnone— no animation (instant swap)
Step 4: Add shared element transitions
Give blog post titles a transition:name so they animate between the list and detail page:
In src/pages/blog/index.astro, update the post title:
<h3 transition:name={`post-title-${post.id}`}>{post.data.title}</h3>In src/layouts/BlogPost.astro, update the title:
<h1 transition:name={`post-title-${Astro.url.pathname.split('/').pop()}`}>{title}</h1>Teaching moment: When both pages have an element with the same transition:name, Astro animates between them — the title smoothly morphs from the list view to the detail view. This creates a polished, app-like feel.
Step 5: Handle scripts with view transitions
If you have any <script> tags that set up event listeners:
<script>
// This runs on every navigation (not just the first page load)
document.addEventListener('astro:page-load', () => {
// Setup code here
console.log('Page loaded:', window.location.pathname);
});
</script>Teaching moment: With view transitions, regular scripts only run on the first page load. The astro:page-load event fires on every navigation, including the initial load. Use it for any setup code that needs to run on each page.
Step 6: Verify
- Navigation between pages is instant (no white flash)
- Content fades in/out smoothly
- Blog post titles animate between list and detail views
- Browser back/forward buttons work correctly
- Any scripts still work after navigation
Reflect: Load reflect/view-transitions.md
---
Part 3: Build & Deploy (~15 min)
Step 1: Understand output modes
Explain the three modes:
static (default — what we're using)
├── All pages pre-built as HTML files
├── No server needed — deploy anywhere
├── Best performance
└── Use for: blogs, docs, portfolios, marketing sites
hybrid
├── Static by default + opt-in SSR per page
├── Add `export const prerender = false` to SSR pages
├── Use for: mostly static + login pages, API routes, user dashboards
server
├── All pages rendered per request
├── Requires a server runtime
├── Use for: highly dynamic apps (dashboards, SaaS)For our blog, static is perfect — all content is known at build time.
Step 2: Build the site
npm run buildExplore the output:
ls dist/Teaching moment: Point out the structure:
dist/
├── index.html ← Home page
├── about/index.html ← About page
├── blog/
│ ├── index.html ← Blog index
│ ├── first-post/index.html
│ ├── second-post/index.html
│ └── third-post/index.html
├── rss.xml ← RSS feed
└── _astro/ ← CSS and JS bundles (only for islands!)Open a static page (like about/index.html). Notice: minimal or zero <script> tags. Open a page with islands: you'll see targeted script tags only for the interactive components.
Step 3: Preview the built site
npm run previewVisit http://localhost:4321/ — this serves the dist/ folder exactly as a hosting provider would. Verify everything works:
- All pages load
- Links work
- Islands are interactive
- View transitions work
- RSS feed accessible
Step 4: Deploy
Option A: Static hosting (no adapter needed)
For static sites, deploy the dist/ folder to any static host:
- Netlify:
npx netlify deploy --dir=dist --prod(or connect Git repo) - Vercel:
npx vercel --prod(auto-detects Astro) - GitHub Pages: Push
dist/to thegh-pagesbranch - Cloudflare Pages: Connect Git repo, set build command to
npm run build
Walk the user through one option (suggest Netlify or Vercel as simplest for beginners).
Option B: SSR hosting (adapter required)
If they later want SSR (hybrid or server mode):
npx astro add netlify # or vercel, node, cloudflareThis installs the adapter and configures astro.config.mjs.
Step 5: What to explore next
After completing the tutorial, suggest areas for growth:
- Server Islands (
server:defer) — Personalized content on static pages - Astro Actions — Type-safe form handling
- Sessions — Server-side user state
- i18n Routing — Multilingual sites
- MDX — Use components inside Markdown
- Image optimization —
<Image />and<Picture />components
All of these are covered in the astro-framework skill references.
Step 6: Verify
npm run buildcompletes without errorsnpm run previewserves a working site- All pages accessible, islands interactive
- (Optional) Site deployed and accessible online
Reflect: Load reflect/deploy.md
---
Lesson 3 Complete! Tutorial Finished!
What you built in Lesson 3:
- Interactive islands with Preact (LikeButton, SearchPosts)
- Client directives (
client:load,client:idle) for selective hydration - View transitions with
ClientRouterfor SPA-like navigation - Shared element transitions between pages
- Production build and deployment
What you built across all 3 lessons:
- A complete personal blog with Astro
- Pages, components, layouts with Props and Slots
- Scoped and global CSS
- Content collections with Zod schema validation
- Dynamic routes with
getStaticPaths() - Blog index with sorting, filtering, pagination
- RSS feed as an API endpoint
- Interactive islands shipping minimal JavaScript
- View transitions for smooth navigation
- Production build ready for deployment
What you deeply understand:
- Astro's component model: frontmatter runs on the server, templates generate HTML
- File-based routing: files → URLs, dynamic routes with
getStaticPaths() - Content Layer: loaders, schemas, querying, rendering
- Islands architecture: zero JS default, selective hydration with client directives
- The decision framework for when to use static vs interactive components
Git commit:
git add .
git commit -m "Lesson 3: Islands, view transitions, and production deployment"Congratulations! You now have the foundation to build any content-driven website with Astro. The patterns you've learned — components, content collections, islands — scale from personal blogs to documentation sites to e-commerce storefronts.
Common Errors
A catalog of errors you may encounter during the tutorial, organized by category. Each entry has: Symptom, Cause, Solution.
Table of Contents
- Project Setup Errors
- Component Errors
- Content Collection Errors
- Routing Errors
- Styling Errors
- Islands / Hydration Errors
- View Transitions Errors
- Build / Deploy Errors
---
Project Setup Errors
"Cannot find module 'astro'"
Symptom: Error when running any astro command
Cause: Dependencies not installed, or running the command outside the project directory
Solution: 1. Make sure you're in the project directory: cd my-blog 2. Install dependencies: npm install 3. Try again: npm run dev
Port 4321 already in use
Symptom: Error: listen EADDRINUSE: address already in use :::4321
Cause: Another process (perhaps a previous dev server) is using port 4321
Solution: 1. Kill the existing process: lsof -i :4321 then kill <PID> 2. Or use a different port: npm run dev -- --port 3000
Node.js version too old
Symptom: Syntax errors or "unsupported engine" warnings during npm create astro
Cause: Astro requires Node.js 18+
Solution: 1. Check your version: node -v 2. If below 18, update Node.js: visit nodejs.org or use nvm install 18
---
Component Errors
"window is not defined" / "document is not defined"
Symptom: Error during build or dev server startup
Cause: Accessing browser-only APIs (window, document, localStorage) in the component frontmatter, which runs on the server
Solution: Move browser API usage to a <script> tag (runs in browser) or into a framework component with a client: directive:
<!-- Wrong: browser API in frontmatter -->
---
const width = window.innerWidth; // ERROR!
---
<!-- Right: browser API in script tag -->
<script>
const width = window.innerWidth;
console.log(width);
</script>Props not passing correctly
Symptom: Component renders but props are undefined
Cause: Forgot to define interface Props or destructure from Astro.props
Solution:
---
interface Props {
title: string;
}
const { title } = Astro.props; // Must destructure from Astro.props
---
<h1>{title}</h1>Slot content not rendering
Symptom: Content passed to a component doesn't appear
Cause: Missing <slot /> in the component template
Solution: Add <slot /> where child content should appear:
<!-- Layout.astro -->
<main>
<slot /> <!-- This is required for child content to render -->
</main>"Expected a default export"
Symptom: Error when importing a component
Cause: Component file is empty or has no template section
Solution: Ensure the .astro file has at least a template (HTML) section, even if the frontmatter is empty.
---
Content Collection Errors
"Content config file not found"
Symptom: Content collections don't work; getCollection() returns nothing or errors
Cause: Using the wrong config file path. Astro 5+ uses src/content.config.ts, NOT src/content/config.ts (the Astro 4 path)
Solution: Rename or move your config file:
# If you have src/content/config.ts, move it:
mv src/content/config.ts src/content.config.tsSchema validation error
Symptom: Build error mentioning Zod validation failure on a specific field
Cause: A blog post's frontmatter doesn't match the schema. Common culprits: missing required field, wrong date format, wrong type.
Solution: 1. Read the error message — it tells you exactly which field failed and why 2. Fix the frontmatter in the offending file 3. Example: if pubDate fails, ensure it's a valid date string: pubDate: 2024-01-15
"Cannot find module 'zod'"
Symptom: Import error when defining schemas
Cause: Importing Zod from the zod package instead of astro/zod
Solution:
// Wrong:
import { z } from 'zod';
// Right:
import { z } from 'astro/zod';"Collection not found"
Symptom: getCollection('blog') returns undefined or errors
Cause: Collection name mismatch between content.config.ts and usage, or the config file isn't exporting collections
Solution: Ensure the name matches exactly:
// In content.config.ts:
export const collections = { blog: blogCollection };
// In your page:
const posts = await getCollection('blog'); // Must match 'blog'"entry.render() is not a function"
Symptom: Error when trying to render a content entry
Cause: Astro 5 changed the render API. entry.render() was the Astro 4 pattern.
Solution: Use the new top-level render() import:
// Wrong (Astro 4):
const { Content } = await entry.render();
// Right (Astro 5+):
import { render } from 'astro:content';
const { Content } = await render(entry);---
Routing Errors
404 on dynamic routes
Symptom: Blog post URLs return 404
Cause: Missing getStaticPaths() in the dynamic route file (required in static mode)
Solution: Add getStaticPaths() that returns all possible paths:
---
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}));
}
---"getStaticPaths() function required for dynamic routes"
Symptom: Build error on a [slug].astro or [...slug].astro file
Cause: Dynamic route files MUST export getStaticPaths() in static output mode
Solution: Add the function as shown above.
Wrong URL structure
Symptom: Pages exist but at unexpected URLs
Cause: File placement doesn't match expected URL
Solution: Remember the mapping:
src/pages/blog/index.astro→/blogsrc/pages/blog/[slug].astro→/blog/my-postsrc/pages/blog/[...slug].astro→/blog/any/nested/path
---
Styling Errors
Styles not applying to child components
Symptom: CSS in a parent component doesn't affect elements in child components
Cause: Astro scopes CSS by default — styles only affect the component they're defined in
Solution: Use :global() to target child elements, or pass classes as props:
<style>
/* Scoped: only affects elements in THIS component */
h1 { color: blue; }
/* Global: affects elements everywhere */
:global(.child-class) { color: red; }
</style>Global styles overriding everything
Symptom: Styles defined in one place affect the entire site
Cause: Using is:global too broadly or importing a CSS file that doesn't use scoped styles
Solution: Remove is:global from <style> tags unless intentional. Use scoped styles by default.
---
Islands / Hydration Errors
Component renders but isn't interactive
Symptom: A React/Preact component appears on the page but clicking buttons does nothing
Cause: Missing client: directive. Without it, the component renders to static HTML only.
Solution: Add the appropriate directive:
<!-- Static (no interactivity): -->
<Counter />
<!-- Interactive: -->
<Counter client:load />"Cannot find package 'preact'" (or react, vue, svelte)
Symptom: Error when using a framework component
Cause: The framework integration isn't installed
Solution: Install it using Astro's CLI:
npx astro add preactThis installs the package AND configures astro.config.mjs automatically.
client:only without framework name
Symptom: Error or unexpected behavior with client:only
Cause: client:only requires specifying which framework to use
Solution: Always include the framework name:
<!-- Wrong: -->
<Component client:only />
<!-- Right: -->
<Component client:only="preact" />Hydration mismatch warning
Symptom: Console warning about server/client HTML mismatch
Cause: Component renders differently on server vs client (common with random values, dates, or browser-dependent logic)
Solution: Ensure the component renders the same output on both server and client. For browser-dependent rendering, use client:only to skip server rendering entirely.
---
View Transitions Errors
Scripts not re-running after navigation
Symptom: JavaScript in <script> tags only works on the first page load
Cause: With view transitions, scripts don't re-execute on navigation by default
Solution: Listen to the astro:page-load event:
<script>
document.addEventListener('astro:page-load', () => {
// This runs on every navigation, including the first page load
setupMyFeature();
});
</script>"ViewTransitions is not exported from 'astro:transitions'"
Symptom: Import error for ViewTransitions
Cause: Astro 5 renamed ViewTransitions to ClientRouter
Solution:
<!-- Wrong (Astro 4): -->
import { ViewTransitions } from 'astro:transitions';
<ViewTransitions />
<!-- Right (Astro 5+): -->
import { ClientRouter } from 'astro:transitions';
<ClientRouter />---
Build / Deploy Errors
"Astro.request is not available in prerendered pages"
Symptom: Build error referencing Astro.request
Cause: Accessing request-time data (headers, cookies, URL params) in a page rendered at build time
Solution: Either: 1. Mark the page as server-rendered: export const prerender = false; 2. Or switch to hybrid output mode in astro.config.mjs
Build fails with "missing adapter"
Symptom: Error when building with output: 'hybrid' or output: 'server'
Cause: SSR modes require a deployment adapter
Solution: Install an adapter:
npx astro add netlify # or vercel, node, cloudflareBuild succeeds but pages are blank
Symptom: HTML files in dist/ are empty or have no content
Cause: Components or pages have errors that silently fail during build
Solution: Check npm run build output carefully for warnings. Run npm run preview to test the built site locally.
Verification Guide
Systematic testing procedures for each component type built during the tutorial.
Project Verification
Run after initial setup (Lesson 1, Part 1):
# 1. Dev server starts without errors
npm run dev
# 2. Check the terminal output for:
# - "Local: http://localhost:4321/"
# - No error messages
# 3. In the browser:
# - http://localhost:4321/ loads the home page
# - http://localhost:4321/about loads the about page
# - Navigation links work between pages
# 4. Hot reload works:
# - Edit index.astro, save, see changes in browser without manual refreshComponent Verification
Run after creating components and layouts (Lesson 1, Part 2):
# 1. All pages use the layout
# - View source in browser: check for consistent HTML structure
# - Header/footer appear on every page
# 2. Props pass correctly
# - Card component renders with different title/body values
# - Change a prop value, verify the output changes
# 3. Slots render child content
# - Content passed to BaseLayout appears inside <main>
# - Named slots (if used) render in the correct position
# 4. Imports work
# - No "Cannot find module" errors in terminal
# - Components render where placed in templatesStyling Verification
Run after adding styles (Lesson 1, Part 3):
# 1. Scoped styles apply only to their component
# - Inspect element in browser DevTools
# - Look for Astro's hash-based class attributes (e.g., class="astro-XXXXXX")
# - Verify styles from one component don't leak to others
# 2. Global styles apply site-wide
# - Body font, colors, reset styles apply on all pages
# - Check that global.css is imported in the layout
# 3. class:list works
# - Conditional classes appear/disappear based on conditions
# - Inspect element to verify correct classesContent Collection Verification
Run after setting up content collections (Lesson 2, Part 1):
# 1. Build catches schema errors
npm run build
# Should succeed with valid posts. To test validation:
# - Temporarily remove 'title' from a post's frontmatter
# - Run npm run build — should show a clear Zod validation error
# - Restore the title
# 2. getCollection() returns typed data
# In your page, temporarily add:
# console.log(posts.length, posts[0].data.title);
# Check the terminal output during dev
# 3. Content config is found
# If getCollection returns empty, check:
# - File is at src/content.config.ts (not src/content/config.ts)
# - File exports `collections` object
# - Collection names match between config and usageDynamic Route Verification
Run after creating dynamic routes (Lesson 2, Part 2):
# 1. All expected URLs resolve
# - Visit /blog/[each-post-slug] in browser
# - None should return 404
# 2. getStaticPaths generates correct params
npm run build
# Check terminal: should list all generated pages like:
# /blog/my-first-post/index.html
# /blog/second-post/index.html
# 3. Content renders correctly
# - Blog post content (markdown) renders as formatted HTML
# - Frontmatter data (title, date, tags) displays correctly
# - Layout wraps the content properly
# 4. Navigation works
# - Blog index links to individual posts
# - Posts link back to blog indexBlog Index Verification
Run after building the blog index (Lesson 2, Part 3):
# 1. Posts are sorted by date (newest first)
# - Check the order on /blog
# 2. Draft posts are filtered out
# - Add draft: true to a post's frontmatter
# - Verify it doesn't appear on /blog
# 3. Pagination works (if implemented)
# - /blog shows first N posts
# - /blog/2 shows next N posts
# - Prev/Next links navigate correctly
# 4. RSS feed is valid
# - Visit /rss.xml in browser
# - Should display XML content
# - Validate at https://validator.w3.org/feed/ (optional)Island Verification
Run after adding interactive components (Lesson 3, Part 1):
# 1. Without client directive: component is static
# - Render a Preact component WITHOUT client:load
# - View source in browser: HTML is there but no JS loaded
# - Click buttons: nothing happens (no interactivity)
# 2. With client directive: component is interactive
# - Add client:load to the component
# - Click buttons: interactivity works
# - Check Network tab: JS bundle loaded for this component
# 3. Different directives behave differently
# - client:load → JS loads immediately
# - client:idle → JS loads after browser idle
# - client:visible → JS loads when component scrolls into view
# 4. JS bundle size is minimal
# - Open Network tab in DevTools
# - Filter by JS
# - Should see small bundles only for island components
# - Static components should NOT appear in JS requestsView Transitions Verification
Run after adding view transitions (Lesson 3, Part 2):
# 1. Navigation is instant
# - Click links between pages
# - Page should NOT do a full reload (no white flash)
# - Watch the Network tab: only fetches HTML, not full page resources
# 2. Animations play
# - transition:animate directives produce visible effects
# - Content fades or slides as configured
# 3. Scripts work after navigation
# - Navigate to a page with interactive elements
# - Verify they still work (not broken by transition)
# - If broken: ensure scripts use astro:page-load eventBuild Verification
Run after completing the site (Lesson 3, Part 3):
# 1. Build completes without errors
npm run build
# 2. Check dist/ folder structure
ls dist/
# Should contain: index.html, about/index.html, blog/*/index.html, etc.
# 3. Static pages have zero or minimal JS
# Check a pure-Astro page (like /about):
# - Open dist/about/index.html
# - No <script> tags (or very minimal)
# 4. Island pages have targeted JS
# Check a page with islands:
# - Open its HTML file
# - Should see <script> tags only for island components
# 5. Preview works
npm run preview
# Visit http://localhost:4321/
# All pages work, all links work, islands are interactiveAuthentication Setup (for testing API routes)
If you've created API endpoints during the tutorial:
# Test GET endpoints
curl http://localhost:4321/rss.xml
# Verify response is valid XML/JSON
# Check status code is 200
# Check Content-Type header matches expected formatReflect: Blog Index & RSS
Let's pause and make sure everything is working before we keep building.
Let's make sure it clicked
1. How does Astro filter draft posts when using `getCollection()`? <details> <summary>Answer</summary>
You pass a filter function as the second argument: getCollection('blog', ({ data }) => !data.draft). This returns only entries where draft is falsy. Since our schema defaults draft to false, posts without an explicit draft: true will be included. </details>
2. Why is the RSS feed a `.ts` file and not `.astro`? <details> <summary>Answer</summary>
It's an API endpoint, not an HTML page. The .ts file exports a GET function that returns an XML Response object (the RSS feed). Astro pages (.astro) always return HTML. API endpoints (.ts/.js) can return any content type — XML, JSON, plain text, etc. </details>
3. What does `paginate()` do in `getStaticPaths`? <details> <summary>Answer</summary>
paginate() takes an array of items and a page size, then automatically generates paginated routes. For example, with 15 posts and pageSize: 5, it creates /blog (page 1), /blog/2 (page 2), /blog/3 (page 3). It also provides page.url.prev, page.url.next, and other navigation helpers. </details>
Quick review
1. Blog Index Page
Show me src/pages/blog/index.astro:
- [ ] Fetches posts with
getCollection('blog') - [ ] Filters out draft posts:
({ data }) => !data.draft - [ ] Sorts by date (newest first)
- [ ] Renders a list of posts with title, description, and date
- [ ] Each post links to
/blog/[slug]
2. RSS Feed
Show me src/pages/rss.xml.ts:
- [ ] Imports
rssfrom'@astrojs/rss' - [ ] Exports a
GETfunction - [ ] Fetches and filters posts (same as blog index)
- [ ] Returns RSS with title, description, site, and items array
- [ ] Each item has title, pubDate, description, and link
3. Configuration
- [ ]
@astrojs/rssis installed (package.json) - [ ]
astro.config.mjshas asiteproperty set - [ ]
BaseLayout.astrohas an RSS<link>in<head>
4. Navigation
- [ ] Navigation includes a "Blog" link pointing to
/blog
If something's not working
RSS package not installed
Cause: @astrojs/rss not in dependencies.
Fix: npx astro add rss or npm install @astrojs/rss
RSS feed shows "site is required"
Cause: Missing site in astro.config.mjs.
Fix: Add site: 'https://example.com' to the config.
Draft posts still showing
Cause: Missing filter in getCollection().
Fix: Add the filter: getCollection('blog', ({ data }) => !data.draft)
Pagination not working
Cause: Wrong file naming for paginated route.
Fix: The file must use rest params: src/pages/blog/[...page].astro (not [page].astro). The ... is required because page 1 is /blog (no param), not /blog/1.
Ready to move on?
- [ ]
/blogshows all published posts sorted by date - [ ] Draft posts (if any) are excluded from the list
- [ ] Each post links to its individual page
- [ ]
/rss.xmlreturns valid XML - [ ] RSS feed includes all published posts
- [ ] Navigation includes Blog link
Go further
These are optional explorations if you're curious — skip them and come back later if you prefer:
- Try implementing pagination with
paginate()in a[...page].astrofile - Add a tag cloud or category filter to the blog index
- Validate your RSS feed at https://validator.w3.org/feed/
Next Steps
Lesson 2 is complete! Commit your progress:
git add .
git commit -m "Lesson 2: Content collections, dynamic routes, blog index, and RSS"Next: Lesson 3 — Interactivity & Production. We'll add interactive islands and prepare for deployment.
Reflect: Components, Props & Layouts
Let's pause and make sure everything is working before we keep building.
Let's make sure it clicked
1. What is the difference between the frontmatter and the template in an Astro component? <details> <summary>Answer</summary>
The frontmatter (between --- fences) is server-side JavaScript that runs at build time — imports, data fetching, prop access happen here. The template is HTML that gets sent to the browser, with {expressions} for dynamic content. The frontmatter is never visible to the user. </details>
2. How do you pass data to a component? <details> <summary>Answer</summary>
Via attributes when using the component: <Card title="Hello" />. The component accesses them through Astro.props: const { title } = Astro.props;. You define the expected shape with interface Props { title: string; } for type safety. </details>
3. What does `<slot />` do in a layout? <details> <summary>Answer</summary>
<slot /> is a placeholder that renders whatever child content the parent passes in. When you write <BaseLayout><h1>Hello</h1></BaseLayout>, the <h1>Hello</h1> appears where <slot /> is placed in BaseLayout. It's similar to React's children prop. </details>
4. Why don't we need `import React from 'react'` in `.astro` files? <details> <summary>Answer</summary>
Astro has its own template syntax — it's not JSX. While it looks similar (using {expressions}), it's actually HTML with Astro-specific features. You use class instead of className, for instead of htmlFor, and you don't need a React runtime at all. </details>
Quick review
1. Component Files Exist
ls src/components/ src/layouts/Expected:
src/components/:
Card.astro Navigation.astro
src/layouts/:
BaseLayout.astro2. Card Component
Show me src/components/Card.astro:
- [ ] Has
interface Propswithtitle: stringandbody: string - [ ] Destructures from
Astro.props - [ ] Renders title and body in the template
- [ ] Optional
hrefprop used for linking
3. BaseLayout
Show me src/layouts/BaseLayout.astro:
- [ ] Imports Navigation component
- [ ] Has
interface Propswithtitle: string - [ ] Contains full HTML structure (
<html>,<head>,<body>) - [ ] Uses
{title}in<title>tag - [ ] Has
<slot />in the<main>section - [ ] Includes Navigation and Footer
4. Pages Use Layout
- [ ]
index.astroimports and uses<BaseLayout title="..."> - [ ]
about.astroimports and uses<BaseLayout title="..."> - [ ] Neither page has its own
<html>or<head>tags (layout handles those)
If something's not working
Component not rendering
Cause: Forgot to import the component in the frontmatter.
Fix: Add import Card from '../components/Card.astro'; in the --- fences.
Props are undefined
Cause: Not destructuring from Astro.props.
Fix: Use const { title } = Astro.props; (not just const title = ...).
Slot content not appearing
Cause: Missing <slot /> in the layout template.
Fix: Add <slot /> where child content should render.
TypeScript error on Props
Cause: Missing or incorrect interface Props definition.
Fix: Ensure the interface matches what you're passing: interface Props { title: string; }.
Ready to move on?
- [ ] All pages render with the shared layout (Navigation + Footer visible)
- [ ] Card component renders with different props on the home page
- [ ] Navigation appears on every page
- [ ] Changing the layout updates all pages at once
- [ ] No duplicated HTML boilerplate in page files
Go further
These are optional explorations if you're curious — skip them and come back later if you prefer:
- Try creating a named slot:
<slot name="sidebar" />and pass content to it withslot="sidebar" - Create a component that conditionally renders based on a boolean prop
- Explore
Astro.slots.has('sidebar')for conditional slot rendering
Next Steps
All good? Here's what's next:
1. Components and Layout are working with Props and Slots 2. Next: Add styling — scoped CSS, global styles, and class:list (Part 3 of Lesson 1)
Ready to continue?
Reflect: Content Collections
Let's pause and make sure everything is working before we keep building.
Let's make sure it clicked
1. Why do we use `z.coerce.date()` instead of `z.date()` for frontmatter dates? <details> <summary>Answer</summary>
Frontmatter dates come as strings (e.g., "2024-01-15"), not JavaScript Date objects. z.coerce.date() automatically converts the string to a Date object. z.date() would expect an actual Date object and fail validation. </details>
2. What happens if a blog post has frontmatter that doesn't match the schema? <details> <summary>Answer</summary>
Astro shows a clear build error with the exact field that failed validation and why (e.g., "title" is required). This is one of the main benefits of Content Collections — errors are caught at build time, not after deployment. </details>
3. Why is the config file `src/content.config.ts` and not `src/content/config.ts`? <details> <summary>Answer</summary>
This is the Astro 5+ convention. The file moved from src/content/config.ts (Astro 4) to src/content.config.ts (at the src root) as part of the Content Layer API redesign. Using the old path will cause collections not to be found. </details>
4. What does the `glob` loader do? <details> <summary>Answer</summary>
The glob loader reads all files matching a pattern from a directory. Each file becomes one collection entry. The file name (without extension) becomes the entry's id. For example, glob({ base: './src/content/blog', pattern: '**/*.md' }) reads all Markdown files from the blog directory. </details>
Quick review
1. Content Config
Show me src/content.config.ts:
- [ ] File is at
src/content.config.ts(NOTsrc/content/config.ts) - [ ] Imports
defineCollectionfrom'astro:content' - [ ] Imports
globfrom'astro/loaders' - [ ] Imports
zfrom'astro/zod'(NOT from'zod') - [ ] Defines a
blogcollection withglobloader - [ ] Schema includes:
title(string),description(string),pubDate(coerce.date) - [ ] Exports
collectionsobject with theblogkey
2. Blog Posts
ls src/content/blog/Expected: At least 3 .md files with valid frontmatter matching the schema.
3. Usage in Pages
- [ ]
index.astrousesgetCollection('blog')to fetch posts - [ ] Posts are sorted by date (newest first)
- [ ] Each post displays title, description, and links to its URL
4. Schema Validation Works
- [ ]
npm run buildsucceeds with valid posts - [ ] Temporarily removing a required field (like
title) causes a clear build error
If something's not working
"Content config file not found"
Cause: Config file at wrong path.
Fix: Ensure file is at src/content.config.ts (not inside src/content/).
"Cannot find module 'zod'"
Cause: Wrong import path.
Fix: Change import { z } from 'zod' to import { z } from 'astro/zod'.
getCollection returns empty array
Cause: Collection name mismatch or missing export const collections.
Fix: Verify the name in getCollection('blog') matches the key in export const collections = { blog: ... }.
Schema validation error on dates
Cause: Using z.date() instead of z.coerce.date().
Fix: Change to pubDate: z.coerce.date().
Ready to move on?
- [ ]
npm run buildsucceeds - [ ] Home page lists blog posts from the collection
- [ ] Posts are sorted by date (newest first)
- [ ] Post data (title, description) comes from frontmatter
- [ ] Adding a post with invalid frontmatter causes a build error
- [ ] TypeScript autocomplete works for
post.data.titleetc.
Go further
These are optional explorations if you're curious — skip them and come back later if you prefer:
- Try adding a new field to the schema (e.g.,
author: z.string()) and see how it propagates - Create a second collection (e.g.,
projects) with a different schema - Experiment with
z.enum()for a field likecategory: z.enum(['tech', 'life', 'tutorial'])
Next Steps
All good? Here's what's next:
1. Content collections are set up with schema validation 2. Next: Create dynamic routes to give each blog post its own page (Part 2 of Lesson 2)
Ready to continue?
Reflect: Build & Deploy
Let's pause and make sure everything is working before we keep building.
Let's make sure it clicked
1. What is the difference between `static`, `hybrid`, and `server` output modes? <details> <summary>Answer</summary>
Static (default): All pages are pre-built as HTML files at build time. No server needed — deploy anywhere. Best performance.
Hybrid: Static by default, with opt-in SSR per page (add export const prerender = false). Good for mostly-static sites that need a few dynamic pages (login, dashboard, API routes).
Server: All pages rendered per request on a server. Needed for highly dynamic applications. Requires a deployment adapter. </details>
2. When do you need a deployment adapter? <details> <summary>Answer</summary>
Only for hybrid or server output modes — these need a server runtime to handle on-demand rendering. Static sites don't need an adapter because they're just HTML files that any web server can serve. Adapters are platform-specific: @astrojs/netlify, @astrojs/vercel, @astrojs/node, @astrojs/cloudflare. </details>
3. What does `npm run build` produce for a static Astro site? <details> <summary>Answer</summary>
A dist/ folder containing plain HTML, CSS, and JavaScript files — ready to upload to any static hosting provider. Each page becomes an index.html inside a directory matching its URL path. The _astro/ subfolder contains hashed CSS and JS bundles (only for pages with islands). </details>
Quick review
1. Build Succeeds
npm run build- [ ] Build completes without errors
- [ ] Terminal shows list of generated pages
2. Output Structure
ls dist/- [ ]
index.htmlexists (home page) - [ ]
about/index.htmlexists - [ ]
blog/index.htmlexists - [ ]
blog/[slug]/index.htmlexists for each post - [ ]
rss.xmlexists - [ ]
_astro/folder contains CSS and (minimal) JS bundles
3. Preview Works
npm run preview- [ ] Site loads at
http://localhost:4321/ - [ ] All pages are accessible
- [ ] Navigation works
- [ ] Islands are interactive
- [ ] View transitions work
- [ ] RSS feed is accessible
4. JavaScript Audit
- [ ] Static pages (about, blog index) have zero or near-zero JS
- [ ] Pages with islands have targeted, small JS bundles
- [ ] No unnecessary JavaScript is shipped
5. Deployment (optional)
If deployed:
- [ ] Site is accessible at the deployed URL
- [ ] All pages load correctly
- [ ] Islands work in production
If something's not working
Build error: "Astro.request is not available in prerendered pages"
Cause: Using request-time APIs (Astro.request, Astro.cookies) in a statically rendered page.
Fix: Either remove the request-time code, or mark the page for SSR: export const prerender = false; (requires hybrid output mode and an adapter).
Build error: "missing adapter"
Cause: Using output: 'hybrid' or 'server' without installing an adapter.
Fix: Install one: npx astro add netlify (or vercel/node/cloudflare).
Preview shows 404 for some pages
Cause: Pages that failed silently during build.
Fix: Check the build output for warnings. Run npm run build again and look for any skipped pages.
Large JS bundle
Cause: Too many components with client:load, or a heavy framework component.
Fix: Audit your client directives. Consider using client:idle or client:visible for non-critical components. Check if a <script> tag could replace a framework component.
Ready to move on?
- [ ]
npm run buildcompletes without errors - [ ]
npm run previewserves a working site - [ ] All pages load correctly
- [ ] All links work
- [ ] Islands are interactive in preview
- [ ] View transitions work in preview
- [ ] RSS feed is valid
- [ ] JavaScript payload is minimal
Go further
These are optional explorations if you're curious — skip them and come back later if you prefer:
- Try switching to
output: 'hybrid'inastro.config.mjsand marking one page asexport const prerender = false - Explore the
_astro/directory indist/— compare JS bundle sizes between pages with and without islands - Set up a simple CI/CD pipeline (GitHub Actions → Netlify/Vercel) for automatic deploys
Tutorial Complete!
Commit your final work:
git add .
git commit -m "Lesson 3: Islands, view transitions, and production deployment"What you've accomplished:
- Built a complete personal blog with Astro
- Mastered the component model, content layer, and islands architecture
- Created type-safe content collections with Zod schemas
- Added interactive components with selective hydration
- Implemented smooth view transitions
- Produced a production-ready, deployable site
Where to go next:
- Explore SSR with
hybridmode for dynamic features - Try server islands (
server:defer) for personalized content - Add Astro Actions for type-safe form handling
- Set up i18n routing for multilingual content
- All covered in the
astro-frameworkskill references
Reflect: Dynamic Routes
Let's pause and make sure everything is working before we keep building.
Let's make sure it clicked
1. Why do we need `getStaticPaths()` for `[...slug].astro`? <details> <summary>Answer</summary>
In static mode (Astro's default), all pages must be generated at build time. Astro needs to know every possible URL in advance to create the corresponding HTML files. getStaticPaths() tells Astro: "Here are all the pages you need to create and what data each one needs." </details>
2. What does `render()` from `astro:content` return? <details> <summary>Answer</summary>
It returns an object with a Content component (the rendered Markdown as an Astro component you can use in your template) and headings (an array of heading elements found in the content). In Astro 5+, you import render from 'astro:content' and call it as render(entry). </details>
3. What is the `...` in `[...slug]` for? <details> <summary>Answer</summary>
The ... makes it a "rest parameter" that captures the entire remaining URL path, including nested segments. For example, [...slug].astro matches /blog/my-post but also /blog/2024/my-post. Plain [slug].astro would only match a single segment. </details>
Quick review
1. Dynamic Route File
Show me src/pages/blog/[...slug].astro:
- [ ] Exports
getStaticPaths()function - [ ]
getStaticPathscallsgetCollection('blog') - [ ] Returns
params: { slug: post.id }for each post - [ ] Returns
props: { post }for each post - [ ] Uses
render(post)from'astro:content'(notpost.render()) - [ ] Destructures
{ Content }from the render result - [ ] Wraps content in
BlogPostlayout
2. Blog Post Layout
Show me src/layouts/BlogPost.astro:
- [ ] Accepts props:
title,pubDate,description,tags - [ ] Uses BaseLayout for consistent site structure
- [ ] Displays formatted date
- [ ] Shows tags (if present)
- [ ] Has
<slot />for the rendered Markdown content
3. URLs Work
- [ ]
/blog/first-postloads the first blog post - [ ]
/blog/second-postloads the second blog post - [ ]
/blog/third-postloads the third blog post - [ ] None return 404
If something's not working
404 on blog post URLs
Cause: getStaticPaths() missing or returning wrong params.
Fix: Ensure you're returning params: { slug: post.id } for each post. The slug param name must match the [...slug] in the filename.
"entry.render() is not a function"
Cause: Using the Astro 4 API. In Astro 5+, render is a standalone import.
Fix:
// Wrong (Astro 4):
const { Content } = await post.render();
// Right (Astro 5+):
import { render } from 'astro:content';
const { Content } = await render(post);Content renders but looks unstyled
Cause: Markdown-generated HTML doesn't have your component's scoped class hashes.
Fix: Add styles for Markdown elements using :global() in the BlogPost layout, or use a global CSS file for article content styling.
Missing layout wrapper
Cause: Content renders without navigation/footer.
Fix: Ensure BlogPost.astro uses BaseLayout internally.
Ready to move on?
- [ ] Each blog post has its own URL at
/blog/[slug] - [ ] Post title, date, and tags display correctly
- [ ] Markdown content renders as formatted HTML
- [ ] Layout wraps each post (navigation, footer visible)
- [ ] Links from the home page navigate to individual posts
- [ ]
npm run buildlists all generated blog post pages
Go further
These are optional explorations if you're curious — skip them and come back later if you prefer:
- Try creating a tag page:
src/pages/tags/[tag].astrothat lists posts by tag - Explore what happens if you rename
[...slug].astroto[slug].astro(without the rest param) - Add previous/next post navigation to the blog post layout
Next Steps
All good? Here's what's next:
1. Dynamic routes generate a page for each blog post 2. Next: Build the blog index with sorting, filtering, and RSS (Part 3 of Lesson 2)
Ready to continue?
Reflect: Islands — Interactive Components
Let's pause and make sure everything is working before we keep building.
Let's make sure it clicked
1. What happens when you use a Preact component WITHOUT a `client:` directive? <details> <summary>Answer</summary>
The component is rendered to static HTML on the server. The HTML appears in the page, but no JavaScript is sent to the browser. The component looks correct but has zero interactivity — clicking buttons, typing in inputs, etc., won't do anything. </details>
2. What is the difference between `client:load` and `client:idle`? <details> <summary>Answer</summary>
client:load hydrates the component immediately when the page loads — the JavaScript is loaded as a high-priority resource. client:idle waits until the browser is idle (via requestIdleCallback) before loading the JavaScript. Use client:load for immediately needed interactivity and client:idle for lower-priority features. </details>
3. Why is Astro's approach called "islands architecture"? <details> <summary>Answer</summary>
The page is like an ocean of static HTML (no JavaScript) with small "islands" of interactivity where needed. Most of the page — headers, content, footers — is static. Only the components that truly need client-side JavaScript (like buttons, forms, search) get hydrated as islands. </details>
4. When should you NOT use a client directive? <details> <summary>Answer</summary>
When the component has no interactivity — which should be the vast majority of your components (~90%+). Headers, footers, cards, blog content, navigation links — these are all static and should remain as .astro components or framework components without client directives. Only add a directive when you need event handlers, state changes, or other client-side behavior. </details>
Quick review
1. Preact Integration
- [ ]
@astrojs/preactis inpackage.jsondependencies - [ ]
astro.config.mjsincludes the Preact integration
2. LikeButton Component
Show me src/components/LikeButton.tsx:
- [ ] Uses
useStatefrompreact/hooks - [ ] Has a click handler that updates state
- [ ] Displays the current like count
- [ ] Exports a default function component
3. LikeButton Usage
- [ ] Used in BlogPost layout or blog post pages
- [ ] Has
client:loaddirective - [ ] Button is interactive (clicking increments count)
4. SearchPosts Component (if implemented)
- [ ] Accepts posts data as props
- [ ] Filters posts based on text input
- [ ] Uses
client:idledirective - [ ] Search input works and filters results
5. Zero-JS Verification
Open the Network tab in DevTools:
- [ ] Pages without islands load zero or minimal JavaScript
- [ ] Pages with islands load small, targeted JS bundles
- [ ] Removing
client:loadfrom LikeButton makes it non-interactive (confirms the directive is doing its job)
If something's not working
"Cannot find package 'preact'"
Cause: Preact integration not installed.
Fix: npx astro add preact (installs both the package and configures the integration).
Component renders but buttons don't work
Cause: Missing client: directive.
Fix: Add client:load (or client:idle / client:visible) to the component tag.
"Cannot find module 'preact/hooks'"
Cause: Preact package not installed correctly.
Fix: npm install preact @astrojs/preact
Hydration mismatch warning
Cause: Component renders differently on server vs client.
Fix: Ensure the initial render is deterministic (no random values, no window checks during render). If the component can't render on the server, use client:only="preact".
Ready to move on?
- [ ] LikeButton is visible on blog post pages
- [ ] Clicking the LikeButton increments the count
- [ ] SearchPosts filters posts as you type (if implemented)
- [ ] Network tab confirms small JS bundles for islands only
- [ ] Without client directive, component is static (no interactivity)
- [ ] Rest of the page (header, content, footer) has no associated JS
Go further
These are optional explorations if you're curious — skip them and come back later if you prefer:
- Try using
client:visibleon a component below the fold and watch the Network tab - Create a small component with
client:only="preact"and see how it differs (no SSR) - Experiment: what happens if you pass a function as a prop to an island? (Spoiler: it doesn't work — props must be serializable)
Next Steps
All good? Here's what's next:
1. Islands are working with selective hydration 2. Next: Add view transitions for smooth page navigation (Part 2 of Lesson 3)
Ready to continue?
Reflect: Project Setup & First Pages
Let's pause and make sure everything is working before we keep building.
Let's make sure it clicked
A few questions to check understanding — think about these before peeking at the answers:
1. What does the `src/pages/` directory do in Astro? <details> <summary>Answer</summary>
Every file in src/pages/ automatically becomes a page on your site. The file path maps directly to the URL: index.astro → /, about.astro → /about. This is called file-based routing — no router configuration needed. </details>
2. Why does `about.astro` become `/about` and not `/about.astro`? <details> <summary>Answer</summary>
Astro strips the file extension during the build process and generates an about/index.html file. This creates clean URLs without file extensions, which is the standard convention for websites. </details>
3. What runs in the frontmatter fence (`---`)? <details> <summary>Answer</summary>
Server-side JavaScript that runs at build time (or request time in SSR mode). This is where you import components, fetch data, and define variables. It does NOT run in the browser — that's why you can't use window or document here. </details>
4. What is the `public/` directory for? <details> <summary>Answer</summary>
Files in public/ are served as-is, without any processing by Astro. This is where you put static assets like favicons, robots.txt, or images that don't need optimization. A file at public/logo.png is accessible at /logo.png. </details>
Quick review
1. Project Structure
Run this command and share the output:
ls src/pages/Expected:
about.astro index.astro2. Dev Server
npm run devExpected: Terminal shows Local: http://localhost:4321/ with no errors.
3. Pages
- [ ]
http://localhost:4321/loads the home page with your name - [ ]
http://localhost:4321/aboutloads the about page - [ ] Both pages have navigation links
- [ ] Clicking links navigates between pages
4. Frontmatter
- [ ]
index.astrohas a frontmatter fence withconst pageTitle = "..."(or similar) - [ ] Template uses
{pageTitle}to display the variable
If something's not working
"Cannot find module 'astro'"
Cause: Dependencies not installed or wrong directory.
Fix: 1. Make sure you're in the project: cd my-blog 2. Install dependencies: npm install 3. Retry: npm run dev
Port 4321 already in use
Cause: Previous dev server still running.
Fix: lsof -i :4321 then kill <PID>, or use npm run dev -- --port 3000
Page shows raw Astro syntax
Cause: Viewing the .astro file directly instead of through the dev server.
Fix: Open http://localhost:4321/ in the browser, not the file path.
Ready to move on?
- [ ] Dev server starts without errors
- [ ] Home page loads and shows content
- [ ] About page loads and shows content
- [ ] Navigation links work between both pages
- [ ] Frontmatter variables render correctly in the template
- [ ] Hot reload works (edit a file, see changes in browser)
Go further
These are optional explorations if you're curious — skip them and come back later if you prefer:
- Try creating a third page (e.g.,
contact.astro) and see it appear at/contact - Experiment with adding more frontmatter variables and using them in the template
- Check out the Astro docs on project structure
Next Steps
All good? Here's what's next:
1. Project structure is set up and working 2. Next: Create reusable components and a shared layout (Part 2 of Lesson 1)
You have duplicated HTML in both pages (the <html>, <head>, navigation). In Part 2, we'll fix this with components and layouts.
Ready to continue? Let me know when all checks pass.
Reflect: Styling
Let's pause and make sure everything is working before we keep building.
Let's make sure it clicked
1. How does Astro scope CSS by default? <details> <summary>Answer</summary>
Astro automatically adds a unique hash-based class attribute to elements (e.g., class="astro-J7PV25F6"). The CSS selectors are rewritten to include this hash, so styles only match elements within the same component. You can see this by inspecting any element in the browser DevTools. </details>
2. When would you use `:global()` or `is:global`? <details> <summary>Answer</summary>
When you need styles to affect elements outside the current component — for example, styling HTML generated by Markdown rendering (which doesn't have your component's hash classes), or targeting child component elements from a parent. Use sparingly to avoid style conflicts. </details>
3. What does `class:list` do? <details> <summary>Answer</summary>
It conditionally applies CSS classes. It accepts an array of strings and objects. Strings are always added. Objects add the key as a class only when the value is truthy. Example: class:list={['card', { featured: isFeatured }]} always adds card but only adds featured when isFeatured is true. </details>
Quick review
1. Scoped Styles
- [ ]
Card.astrohas a<style>tag with component-specific styles - [ ] Styles in Card don't affect other components (inspect in DevTools to verify)
- [ ] Elements have Astro hash class attributes (e.g.,
astro-XXXXXX)
2. Global Styles
- [ ]
src/styles/global.cssexists with base styles and CSS variables - [ ] Global CSS is imported in
BaseLayout.astrofrontmatter - [ ] CSS variables (e.g.,
--color-primary) are defined in:root - [ ] Base styles (fonts, colors, spacing) apply across all pages
3. class:list (if implemented)
- [ ] Card or another component uses
class:listfor conditional classes - [ ] Conditional class appears/disappears based on prop value
If something's not working
Styles not applying to child components
Cause: Scoped CSS can't reach into child component elements.
Fix: Either use :global() selectors, pass classes as props, or define the styles in the child component itself.
Global styles overriding scoped styles
Cause: CSS specificity — global styles may have equal or higher specificity.
Fix: Increase specificity of scoped styles, or organize globals to use low-specificity resets only.
CSS variables not working
Cause: Variables defined in the wrong scope or global CSS not imported.
Fix: Ensure global.css is imported in the layout and variables are defined in :root.
Ready to move on?
- [ ] Cards have visible styling (border, padding, hover effect)
- [ ] Navigation is styled (horizontal links, border)
- [ ] Global styles apply site-wide (font, colors, spacing)
- [ ] Inspecting elements shows Astro's scoped hash classes
- [ ] Styles from one component don't leak to others
Go further
These are optional explorations if you're curious — skip them and come back later if you prefer:
- Try using
is:globalon a<style>tag to see how it differs from scoped - Experiment with CSS
:global()to style elements inside child components - Add a simple dark/light theme toggle using CSS variables
Next Steps
Lesson 1 is complete! Commit your progress:
git add .
git commit -m "Lesson 1: First Astro site with pages, components, layouts, and styling"Next: Lesson 2 — Content Collections & Dynamic Routes. We'll add real blog posts with type-safe schemas.