Astro
A Complete 12-Chapter Course
Table of Contents
- What Astro Is: The Content-First, Zero-JS-by-Default Framework
- Project Setup & the .astro File
- File-Based Routing & Dynamic Routes
- Layouts, Slots & Component Composition
- Content Collections: Structured Content with Type Safety
- Styling in Astro
- Islands Architecture In Depth: Partial Hydration
- Using React, Vue, or Svelte Components Inside Astro
- Data Fetching & API Endpoints
- Rendering Modes: Static, Server & Hybrid
- Markdown & MDX Content Authoring
- Capstone: Building a Content-Driven Site
What Astro Is: The Content-First, Zero-JS-by-Default Framework
React, Vue, Svelte, Angular, and Next.js all share one assumption underneath their real differences: the browser gets a JavaScript runtime, and the page is treated as an application. Astro starts from a genuinely different assumption — most of a real page is content, not application state, and the framework's own default output is plain, static HTML with no JavaScript shipped at all, unless you deliberately ask for some.
Zero JavaScript by Default
Every Astro component renders to plain HTML at build time — including components written in React, Vue, or Svelte and used inside an Astro page. Unlike Next.js, where a React component ships its own JavaScript to the browser and hydrates automatically, an Astro page with ten components on it can ship zero bytes of framework JavaScript by default. Nothing hydrates unless it's told to.
The Islands Architecture
Astro's own name for this model is the islands architecture: picture a page as a mostly static "ocean" of plain HTML, with small, isolated "islands" of interactivity dropped in only where genuinely needed — a like button, a search box, a carousel. Each island hydrates independently, on its own schedule, rather than the whole page hydrating as one unit the way a client-rendered React or Vue app does.
Why "Content-First"?
Astro was built with blogs, documentation sites, marketing pages, and portfolios in mind — sites where the overwhelming majority of the page is content that never changes after it's rendered, and only a handful of small pieces are genuinely interactive. That's a real, different sweet spot from a framework built assuming the whole page is one interactive application from the start.
Four Frameworks, One New Assumption
| Concept | React / Vue / Svelte | Astro |
|---|---|---|
| Ships JS by default? | Yes — a framework runtime, always | No — plain HTML unless a component opts in |
| Hydration model | Whole page/app, at once | Per-component "islands," independently |
| Framework lock-in per page | One framework for the whole app | React, Vue, and Svelte components can coexist on one page |
| Sweet spot | Interactive applications | Content-heavy sites with occasional interactivity |
Creating a Project
The scaffolding wizard offers a few starter templates — an empty project is the clearest one to learn from. npm run dev starts the dev server with live reload, the same as every other framework in this series.
src/pages/— every file here becomes a real page, by convention (Chapter 3)src/components/— reusable.astrocomponentssrc/layouts/— shared page shells (Chapter 4)astro.config.mjs— the project's own configuration file
Coding Challenges
Scaffold a new Astro project, then edit src/pages/index.astro so it renders a heading interpolating a name from a frontmatter variable.
📄 View solutionBuild a Card.astro component that accepts a title prop, use it inside index.astro, and confirm via your browser's View Source that no JavaScript was shipped for it.
📄 View solutionAdd a second page, src/pages/about.astro, with no route configuration written anywhere, and confirm it's served automatically at /about.
📄 View solutionChapter 1 Quick Reference
- Zero JS by default — every component renders to static HTML unless deliberately hydrated
- Islands architecture — small, independent interactive components in an otherwise static page
- Framework-agnostic — React, Vue, and Svelte components can all live on one Astro page
- Content-first — built for blogs, docs, and marketing sites, not assumed-interactive apps
- Not static-only — SSR and hybrid rendering exist too, covered in Chapter 10
- npm create astro@latest / npm run dev — scaffold and run
- Next chapter: the
.astrofile and component syntax
Project Setup & the .astro File
Every .astro file has two parts: a frontmatter fence at the top for plain JavaScript or TypeScript, and a markup section below it. Both parts behave differently from every sibling framework in this series in ways worth understanding precisely, not just by analogy.
The Frontmatter Fence: Runs Once, Not Reactive
The code between the two --- lines runs exactly once — at build time for a static page, or once per request in server-rendered mode (Chapter 10) — and never again after that. There's no re-run on the client, because by default there's no client-side runtime at all. This is a genuinely different mental model from Svelte's own reactive <script> block, which re-runs logic in response to state changes in the browser.
Markup Expressions: JSX-Style, Not a Directive Language
Astro's own markup expressions — { } — contain real JavaScript expressions, evaluated directly: .map() for lists, && or a ternary for conditionals. This is genuinely closer to React's JSX than to Vue's v-for/v-if directives or Svelte's own {#each}/{#if} block syntax — Astro doesn't introduce a separate template directive language at all; the markup expressions are JavaScript.
Props via Astro.props
An optional interface Props gives typed, self-documenting props — genuinely useful in TypeScript projects, and entirely optional. Destructuring Astro.props in the frontmatter is the direct equivalent of a React function component's own props parameter.
Multiple Root Elements — No Wrapping Fragment Needed
Astro components can have multiple top-level elements natively — there's no historical single-root-element rule to work around the way early React needed <Fragment>/<></> for. Vue 3 and Svelte both allow this too; it's React's own older constraint (long since solved by Fragments) that made this worth calling out explicitly.
Frontmatter and Markup, Compared Across Frameworks
| Concept | React (JSX) | Vue / Svelte | Astro |
|---|---|---|---|
| Template expressions | Real JS, embedded directly | A separate directive language (v-for, {#each}) | Real JS, embedded directly — same style as JSX |
| Script re-runs? | Every render (client) | Reactively, on state change | Once, at build/request time — never again by default |
| Multiple root elements | Needs a Fragment | Allowed natively | Allowed natively |
Coding Challenges
Build a List.astro component that accepts an items array prop and renders it as a <ul> using a .map() expression directly in the markup.
📄 View solutionAdd an optional featured boolean prop with a typed Props interface, and conditionally render a badge using a {condition && ...} expression.
📄 View solutionBuild a component with two sibling top-level elements (no wrapping div), and confirm it renders correctly with no Fragment needed.
📄 View solutionChapter 2 Quick Reference
- Frontmatter fence (
---) — plain JS/TS, runs once, never again on the client - Markup expressions (
{ }) — real JavaScript, JSX-style, not a separate directive language Astro.props— destructure props in the frontmatter, optionally typed viainterface Props- No wrapping Fragment needed — multiple top-level elements are allowed natively
- Closest real parallel — a Next.js React Server Component's own server-only execution model
- Next chapter: file-based routing and dynamic routes
File-Based Routing & Dynamic Routes
Chapter 1 briefly showed a plain static file in src/pages/ becoming a real route. This chapter covers the rest of the picture: dynamic segments, catch-all routes for arbitrary-depth paths, and the one real constraint every dynamic route runs into by default — getStaticPaths().
Static Routes, Recapped
src/pages/index.astro→/src/pages/about.astro→/aboutsrc/pages/blog/index.astro→/blog
Folders nest naturally into path segments — no route configuration file exists anywhere in an Astro project.
Dynamic Single-Segment Routes
[slug].astro matches any single path segment at that position — /blog/first-post, /blog/second-post — captured into Astro.params.slug.
getStaticPaths() is exactly that declaration. Visit a URL whose value wasn't returned by getStaticPaths(), and there's simply no pre-built file for it — a real 404, not a route that resolves lazily. Chapter 10's server output mode removes this constraint entirely, resolving dynamic routes per-request instead.
Catch-All Routes: Arbitrary-Depth Paths
[...path].astro is Astro's own catch-all segment — the three dots match zero or more path segments, slashes included, all captured into a single string on Astro.params.path. Same requirement applies: every real deep path this route should serve has to appear in getStaticPaths()'s own returned list.
[...path] folder convention, Django's <path:full_path> converter, Laravel's {path?} plus a regex constraint, and Rails' own *path glob. Astro's [...path].astro is this course's own version of the identical idea — the piece a future Website Rebuild with Astro course would build directly on.
No Client-Side Router by Default
Unlike a single-page application framework, Astro doesn't ship a client-side router at all by default — navigating between pages is a genuine full page load, since there's no persistent client-side app to route within. An optional View Transitions API exists for smoother navigation animations, but it's a later, opt-in refinement, not a default routing mechanism the way React Router or Vue Router are.
Coding Challenges
Build a dynamic route at src/pages/products/[id].astro, with getStaticPaths() returning at least 3 hard-coded IDs, rendering the current ID via Astro.params.
📄 View solutionBuild a catch-all route at src/pages/docs/[...path].astro, with getStaticPaths() covering at least one multi-segment path, rendering the full captured path.
📄 View solutionBuild the site (npm run build) and confirm a URL not covered by getStaticPaths() has no corresponding output file — demonstrating why every real path has to be declared up front in static mode.
📄 View solutionChapter 3 Quick Reference
src/pages/— every file becomes a real route, folders nest into path segments[slug].astro— matches one dynamic path segment, captured viaAstro.params[...path].astro— catch-all, matches an arbitrary-depth path in one segment stringgetStaticPaths()— required in static output mode; every real path must be declared up front, or it's a genuine 404- No client-side router by default — navigation is a real page load unless View Transitions is added
- Next chapter: Layouts, Slots & Component Composition
Layouts, Slots & Component Composition
Every page written so far has repeated its own <html>/<head>/<body> boilerplate. A layout is just an ordinary Astro component, conventionally kept in src/layouts/, that wraps a page's own content instead.
A Basic Layout
Whatever's placed between <BaseLayout> and </BaseLayout> in the page renders wherever <slot /> appears inside the layout itself. title is passed as an ordinary prop, same as any other component — a layout isn't a special kind of file, just a component used in this particular role by convention.
<slot /> is modeled directly on the native browser Web Component slotting API — the same underlying concept the HTML platform itself provides. React's children is just a plain prop with no dedicated syntax; Vue's own <slot> element is closer in spirit to Astro's own, both drawing from the same native idea.
Named Slots
An element with a slot="name" attribute is routed into the matching named <slot name="..." />; everything else falls into the unnamed default slot.
Nesting Layouts
A layout can wrap another layout — BlogPostLayout adds blog-specific chrome (a publish date) around its own <slot />, while still passing through to BaseLayout for the shared <html> shell. Layouts compose the same way any other Astro components do.
Slots, Compared Across Frameworks
| Framework | Mechanism |
|---|---|
| React | The children prop — a plain JavaScript value, no dedicated syntax |
| Vue | <slot>, named via <template #name> |
| Svelte 5 | Snippet props rendered via {@render children()} — replaced the older <slot> mechanism used in Svelte 3/4 |
| Astro | <slot /> and named <slot name="..." />, modeled on the native Web Component API |
Coding Challenges
Build BaseLayout.astro with a full HTML shell, a title prop for the <title> tag, and a default <slot />, then use it from index.astro.
📄 View solutionAdd a named "sidebar" slot to BaseLayout, and pass sidebar content into it from a page using slot="sidebar", alongside default-slot main content.
📄 View solutionBuild a BlogPostLayout.astro that wraps BaseLayout and adds a publishDate prop rendered above its own <slot />, then use it from a blog post page.
📄 View solutionChapter 4 Quick Reference
src/layouts/— an ordinary component used by convention to wrap a page's own content<slot />— the default insertion point, modeled on the native Web Component slotting API<slot name="..." />/slot="..."— named slots for multiple distinct insertion points- Layouts are just components — they take props and can wrap other layouts
- Genuine cross-framework difference — React's plain
childrenprop vs. Vue/Astro's native-style<slot>vs. Svelte 5's newer snippet-based{@render} - Next chapter: Content Collections
Content Collections: Structured Content with Type Safety
Astro's own answer to "structured, validated content" is Content Collections — a real, first-class feature for organizing Markdown/MDX content into typed, schema-validated groups, kept in src/content/.
Defining a Collection
The schema is a real Zod object — Zod is a popular TypeScript-first schema validation library, used here to declare exactly what fields every entry's own frontmatter must have, and of what type. Every Markdown file in src/content/blog/ gets validated against this schema.
title, or writes publishDate as plain text instead of a real date, Astro reports a real, build-time type error — not a silent runtime bug discovered later. TypeScript also infers the exact shape of every entry's own data object directly from this schema, so autocomplete and type-checking work correctly anywhere a collection entry is used in code.
Reading a Collection: getCollection()
getCollection('blog') returns every entry in the collection, each with a validated, typed data object matching the Zod schema above.
Rendering a Single Entry — Connecting Back to getStaticPaths()
Chapter 3's own getStaticPaths() can return a props object alongside params — whatever's passed here becomes directly available as Astro.props in the page, avoiding a second lookup. post.render() returns a real <Content /> component rendering that entry's own Markdown body.
blog collection, a docs collection — but there's no built-in mechanism for a genuinely self-referencing, arbitrary-depth tree the way a real database and ORM provide. Zod's own reference() helper can link one entry to another (a parent field referencing a sibling entry), but that's a schema-level cross-reference, not the same thing as the adjacency-list-plus-materialized-path hybrid every course in the Website Rebuild series independently arrived at. A future Website Rebuild with Astro course would genuinely need to choose between building that reference pattern by hand within Content Collections, or reaching for database-backed content in server output mode (Chapter 10) instead — not assume Content Collections alone already solve the same problem.
Coding Challenges
Define a docs collection with a Zod schema (title: string, order: number), add at least three content files, and use getCollection() to list all entries sorted by order.
📄 View solutionBuild a dynamic route rendering a single docs entry's own Content, passing the entry through getStaticPaths()'s props rather than looking it up a second time.
📄 View solutionDeliberately break one content file's frontmatter (e.g. write order as text instead of a number), run the dev server, and report the exact build-time type error Astro produces.
📄 View solutionChapter 5 Quick Reference
src/content/config.ts— defines every collection and its own Zod schemadefineCollection({ schema: z.object({...}) })— validated, typed frontmattergetCollection('name')— returns every validated, typed entry in a collectionpost.render()— returns a<Content />component for that entry's Markdown bodygetStaticPaths()'s ownprops— passes data straight through, avoiding a second lookup- Honest limit — no built-in self-referencing arbitrary-depth tree; a future rebuild needs
reference()or database-backed content instead - Next chapter: Styling in Astro
Styling in Astro
A <style> block inside an .astro component is scoped to that component automatically — no explicit keyword needed, matching Svelte's own zero-config default rather than Vue's opt-in scoped attribute.
Scoped by Default
Astro adds a unique attribute to this component's own elements at build time, so a different component's own .card rule never collides with this one — the identical technique, and the identical zero-runtime-cost result, Svelte's own scoped styles already used.
Opting Out: is:global
is:global on a specific <style> block disables scoping for just that block, letting its rules apply site-wide from wherever the component happens to be used.
A Real Site-Wide Stylesheet
For genuinely site-wide styling — this project's own established dark theme, for instance — a plain imported .css file in a shared layout's frontmatter is the natural choice, applying unscoped to the whole page.
define:vars: Frontmatter Values Inside CSS
define:vars binds a frontmatter value directly into a real CSS custom property, usable anywhere inside that component's own scoped <style> block — no inline style attribute and no separate CSS-in-JS library required to bridge computed values into CSS.
Sass Support, Out of the Box
Installing the sass package is the only setup required — no bundler configuration to write. lang="scss" on any <style> block enables real Sass syntax, still scoped by default the same as plain CSS.
Scoped Styles, Compared
| Framework | Scoped by Default? |
|---|---|
| React | No — needs CSS Modules or a separate styling library |
| Vue | Opt-in via <style scoped> |
| Svelte | Yes — automatic, zero configuration |
| Astro | Yes — automatic, zero configuration, same mechanism as Svelte |
Coding Challenges
Build two components that each style an h3 tag differently in their own scoped <style> block, and confirm both render with their own distinct styling when placed on the same page.
📄 View solutionUse define:vars to bind a frontmatter color variable into a CSS custom property, applied inside that component's own scoped style block.
📄 View solutionAdd a global.css stylesheet imported from a shared layout applying a site-wide dark background, and confirm a component's own scoped styles still layer correctly on top of it.
📄 View solutionChapter 6 Quick Reference
- Scoped by default —
<style>is automatically scoped, same mechanism as Svelte, unlike Vue's opt-inscoped is:global— opts a specific style block out of scoping- Imported global stylesheet — the right tool for genuinely site-wide styling
define:vars— binds a frontmatter value into a real CSS custom property, no CSS-in-JS library needed- Sass —
npm install sasspluslang="scss", no bundler config required - Next chapter: Islands Architecture In Depth
Islands Architecture In Depth: Partial Hydration
Chapter 1 claimed Astro ships zero JavaScript by default. Here's where that claim gets paid off concretely: client directives are the only mechanism that opts an individual component into hydration — without one, even a React or Svelte component renders to static HTML and nothing more.
No Directive: Static HTML, No Exceptions
Even though Counter is a real React component with its own useState, without a client directive it's treated exactly like any Astro component: rendered once to HTML, then discarded. Clicking the button does nothing.
The Five Client Directives
client:load— hydrates immediately once the page loads. For genuinely critical, above-the-fold interactivity.client:idle— hydrates once the browser's main thread goes idle (viarequestIdleCallback). For interactivity that matters but isn't urgent.client:visible— hydrates only when the component scrolls into the viewport (viaIntersectionObserver). Ideal for anything below the fold.client:media="(query)"— hydrates only when a CSS media query matches — a mobile-only menu, for instance, that never hydrates at all on desktop.client:only="react"— skips server rendering entirely, rendering only in the browser. For components that depend on browser-only APIs that would error during the build.
client:visible and client:media aren't only deferring when a component's JavaScript runs — they defer whether its bundle is even downloaded at all. An island using client:visible placed far down a long page genuinely doesn't fetch its own JS bundle until the user scrolls near it; a client:media-gated mobile menu never downloads its JS on a desktop visit at all. This is a real, measurable difference from a typical single-page application, which downloads its entire framework runtime and every component's code upfront regardless of whether the visitor ever scrolls that far or is on that device.
client:media is genuinely distinctiveChoosing the Right Directive
| Directive | Hydrates When | Best For |
|---|---|---|
| client:load | Immediately | A critical, above-the-fold widget |
| client:idle | Main thread is idle | Important but non-urgent interactivity |
| client:visible | Scrolled into view | Anything below the fold |
| client:media | A media query matches | Device- or viewport-specific components |
| client:only | Client only, no SSR at all | Components using browser-only APIs |
Coding Challenges
Add a React (or Vue/Svelte) counter component to an Astro page with client:load, and confirm the button actually increments when clicked.
📄 View solutionPlace the same counter far down a long page using client:visible instead, and confirm via your browser's Network tab that its JS bundle only loads once it's scrolled into view.
📄 View solutionBuild a mobile-only interactive component (e.g. a hamburger menu) using client:media, and confirm by resizing your browser that it only hydrates below the breakpoint.
📄 View solutionChapter 7 Quick Reference
- No directive — static HTML only, even for a React/Vue/Svelte component; nothing hydrates
client:load— hydrates immediatelyclient:idle— hydrates when the main thread is idleclient:visible— hydrates on scroll into view; defers the JS download, not just executionclient:media="(query)"— hydrates only when a media query matches; unique among this series' frameworksclient:only="react"— client-only rendering, skips SSR entirely- Next chapter: Using React, Vue, or Svelte Components Inside Astro
Using React, Vue, or Svelte Components Inside Astro
This chapter builds directly on this site's own existing react1–react4, vue1, and svelte1 courses — it covers the integration boundary between Astro and those frameworks, not the frameworks' own syntax, which those courses already teach in full.
Adding a Framework Integration
npx astro add [framework] installs the needed packages and wires up astro.config.mjs automatically.
react(), vue(), and svelte() can all be active integrations in the same project simultaneously, with a single page importing a .jsx React component, a .vue Vue component, and a .svelte Svelte component side by side, each hydrating independently as its own island (Chapter 7).
Using a Framework Component
Props are passed as ordinary attributes, the same as any Astro component. Each framework's own file extension (.jsx/.tsx, .vue, .svelte) is imported directly — no wrapping needed.
A Real Constraint: Props Must Be Serializable
A Second Real Constraint: No Direct Cross-Framework Nesting
A React component cannot directly contain a Vue component as a JSX child, and vice versa — the two framework runtimes don't compose that way. Composition across frameworks happens at the Astro layer instead: an Astro component can wrap a framework island and use <slot /> (Chapter 4) to let Astro-level content — including other framework islands — surround it.
Both islands sit side by side inside Panel's own <slot /> — real, valid composition, achieved entirely at the Astro layer rather than one framework component containing the other directly.
react1–react4, vue1, and svelte1 courses already cover that ground in full.
Coding Challenges
Add the React integration, then use a React component in an Astro page passing a plain numeric prop (e.g. initialCount={5}), confirming the value is received correctly.
📄 View solutionDemonstrate the correct way to give a React island its own click handler — defined inside the component itself, not passed in as a function prop from Astro's frontmatter.
📄 View solutionBuild an Astro wrapper component with a <slot />, and use it to compose a React island and a Svelte (or Vue) island side by side inside it.
📄 View solutionChapter 8 Quick Reference
npx astro add [react|vue|svelte]— installs and configures a framework integration- Multiple integrations can coexist — a genuinely unusual trait among frameworks in this series
- Props are serialized — plain data survives; functions/class instances don't cross the boundary as props
- No direct cross-framework nesting — composition happens via an Astro wrapper's own
<slot /> - This chapter stops at the boundary — see
react1–react4/vue1/svelte1for the frameworks themselves - Next chapter: Data Fetching & API Endpoints
Data Fetching & API Endpoints
There are three genuinely different ways to get data into an Astro site, each with its own timing and tradeoffs.
Fetching in the Frontmatter: Build Time, Not Request Time
fetch() call happens at build time in Astro's default static output mode — the response gets baked directly into the pre-rendered HTML. If the API's own data changes afterward, the site won't reflect it until the next build and deploy. This isn't a bug to work around; it's the same static-by-default model already established, just applied to a network request instead of a plain variable.
API Endpoints
Any file in src/pages/api/ exporting a named GET (or POST, PUT, etc.) function returning a real Response becomes a working API endpoint — here, reachable at /api/posts.json. In static output mode, this endpoint is also generated once at build time, the same constraint as the frontmatter fetch above; Chapter 10's server/hybrid modes are what let an endpoint like this run fresh on every request instead.
Client-Side Fetching Inside an Island
A hydrated island (Chapter 7) can fetch data itself, in the browser, at runtime — genuinely fresh every time the component loads, regardless of when the site was last built. The real cost is exactly what Chapter 7 already established: this requires shipping and hydrating real client-side JavaScript, unlike the frontmatter fetch's zero-JS static output.
Three Approaches, Compared
| Approach | Runs When | Ships JS? | Data Freshness |
|---|---|---|---|
Frontmatter fetch() | Build time, once | No | Frozen until next build |
| API endpoint (static mode) | Build time, once | No (unless called from an island) | Frozen until next build |
| Client-side fetch in an island | Every time the component loads, in the browser | Yes | Always current |
Coding Challenges
Fetch data from a public placeholder API inside a page's frontmatter, render it as a list, then rebuild the site and confirm the rendered output only changes on a fresh build, not on every page load.
📄 View solutionBuild a src/pages/api/posts.json.ts endpoint returning a hard-coded JSON array via a GET function, and confirm it's reachable directly by visiting /api/posts.json.
📄 View solutionBuild a client-side-fetching island (client:load) that fetches from your own /api/posts.json endpoint at runtime, and explain how this differs from Challenge 1's frontmatter-fetch approach.
📄 View solutionChapter 9 Quick Reference
- Frontmatter
fetch()— runs once at build time, result frozen into static HTML src/pages/api/*.ts— a namedGET/POSTexport returning aResponsebecomes a real endpoint- Both are static in default mode — Chapter 10's server/hybrid modes remove this constraint
- Client-side fetch in an island — always fresh, but requires shipping real JavaScript
- Next chapter: Rendering Modes: Static, Server & Hybrid
Rendering Modes: Static, Server & Hybrid
Every earlier chapter used Astro's default: output: 'static'. This chapter covers the other side — output: 'server', and mixing both per page — which removes several constraints already flagged along the way, including Chapter 1's own promise that Astro "isn't only for static blogs."
Static Output — the Default, Recapped
Every page pre-rendered to plain HTML files at build time. getStaticPaths() (Chapter 3) is required for any dynamic route, since every possible path has to be known up front — there's no server running afterward to resolve one on demand.
Server Output: Per-Request Rendering
output: 'server' renders every page fresh, per request. Astro itself doesn't ship a production server runtime — an adapter (@astrojs/node, @astrojs/vercel, @astrojs/netlify, and others) is what actually runs the server on a given platform, installed via npx astro add node.
[slug].astro reads Astro.params.slug directly and resolves per request — no getStaticPaths() function needed at all. This is exactly the change Chapter 3's own finding-box promised: the "every path must be known at build time" constraint was specific to static output, not to Astro itself.
Mixing Both: Per-Page Opt-Out
A single page can opt out of the project's own default mode via export const prerender — a mostly-static project can mark one genuinely dynamic page (a live dashboard, an admin panel) for server rendering, while everything else stays pre-built and fast. The reverse works too: in a server-output project, a page that's genuinely static (a privacy policy, an about page) can be marked prerender = true to skip per-request rendering for content that never changes.
output: 'server' (or the per-page opt-out shown above) plus a real adapter, for exactly the same reason: an admin interface reading and writing live data has no meaningful static-build equivalent.
Three Rendering Approaches, Compared
| Static (default) | Server | Per-page opt-out | |
|---|---|---|---|
| When it renders | Once, at build time | Every request | Mixed, page by page |
| Needs an adapter? | No | Yes | Yes, for the dynamic pages |
getStaticPaths() needed? | Yes, for dynamic routes | No | Only for the pages still using static rendering |
| Good fit for | Blogs, docs, marketing pages | An admin panel, live data | A content site with one genuinely dynamic section |
Coding Challenges
Switch a project to output: 'server' with the Node adapter, remove getStaticPaths() from a dynamic route built in Chapter 3, and confirm it still resolves correctly per-request.
📄 View solutionIn that same server-mode project, add export const prerender = true to one specific static page, and confirm it's still pre-rendered at build time while the rest of the site renders per-request.
📄 View solutionBuild a POST API endpoint that only works correctly in server output mode, and explain concretely why a purely static build couldn't support it.
📄 View solutionChapter 10 Quick Reference
output: 'static'— the default; pre-rendered once, requiresgetStaticPaths()for dynamic routesoutput: 'server'— per-request rendering; requires an adapter (@astrojs/node,@astrojs/vercel, etc.)export const prerender— opts a single page into or out of the project's own default mode- Server mode removes
getStaticPaths()entirely — dynamic routes resolve fresh, per request - Rebuild groundwork — a future rebuild course's own admin interface needs server output, the same conclusion every sibling rebuild course already reached
- Next chapter: Markdown & MDX Content Authoring
Markdown & MDX Content Authoring
Chapter 5 covered Content Collections as a structure. This chapter covers what actually goes inside them — plain Markdown for the vast majority of content, and MDX for the real, specific case where a piece of content needs a live, interactive component embedded directly in the prose.
Plain Markdown
Standard Markdown syntax — headings, lists, fenced code blocks — renders exactly as expected. Fenced code blocks get real syntax highlighting automatically, via Astro's own built-in Shiki integration — no separate highlighting library or configuration needed, unlike many other static site tools.
MDX: Markdown with Embedded Components
.mdx files allow real import statements and real component tags directly inside otherwise-plain Markdown prose — requires the @astrojs/mdx integration (npx astro add mdx).
SalesChart above still needs client:visible (or another directive from Chapter 7) exactly the same as anywhere else in an Astro project. MDX changes where a component can be placed, not the rules governing whether it ships JavaScript.
Mixing .md and .mdx in One Collection
A single Content Collection can contain both .md and .mdx files side by side, as long as every entry — regardless of extension — satisfies the same Zod schema defined in src/content/config.ts (Chapter 5). getCollection() returns both kinds of entries together, with no special handling needed to distinguish them.
Markdown vs. MDX, Compared
Markdown (.md) | MDX (.mdx) | |
|---|---|---|
| Can embed live components? | No | Yes |
| Requires an integration? | No, built in | Yes — @astrojs/mdx |
| Right for | The vast majority of prose content | The genuine, specific case needing a live component mid-content |
Coding Challenges
Write a full Markdown blog post in a content collection with multiple headings, a list, and a fenced code block, and confirm it renders with proper syntax highlighting with zero extra configuration.
📄 View solutionConvert that post to MDX and embed a real interactive island component (with a client directive) directly inside the prose, confirming both the text and the component render and work correctly.
📄 View solutionConfirm a collection can mix .md and .mdx entries by adding one of each satisfying the same schema, and list both together via a single getCollection() call.
📄 View solutionChapter 11 Quick Reference
- Plain Markdown — the right default for the vast majority of content, built-in Shiki syntax highlighting
- MDX (
.mdx) — real imports and component tags inside Markdown prose, via@astrojs/mdx - Embedded components still need a client directive — MDX changes placement, not Chapter 7's own hydration rules
- Not the default — MDX is for the specific case, not every post
- Mixed collections —
.mdand.mdxcan coexist in one collection, same schema, samegetCollection()call - Next chapter: Capstone — Building a Content-Driven Site
Capstone: Building a Content-Driven Site
This capstone builds one real, working blog — every earlier chapter contributes a real, working piece of it, not just a concept in isolation.
The Content Collection
Both zero-js-by-default.md and live-demo-post.mdx satisfy this same schema (Chapters 5 and 11).
Listing & Rendering Posts
This is Chapter 3's own getStaticPaths() pattern and Chapter 5's own props-passing shortcut, working together exactly as designed.
An Island for the Newsletter Signup
Placed on the blog index below the fold via <NewsletterSignup client:visible /> — Chapter 7's own genuine performance payoff: its JS never even downloads unless a visitor scrolls that far.
The API Endpoint — and Why It Forces a Rendering-Mode Decision
A real newsletter signup has to accept whatever email a visitor types, at the moment they submit it — exactly the case Chapter 10 identified as needing server output, not a static build. So the finished project's config reflects that decision:
output: 'server' for the whole project means the blog index and post pages now render per-request instead of once at build time — a genuine cost, since Chapter 3's own getStaticPaths() isn't even necessary anymore in this mode. A more finely-tuned version of this same project could keep the blog pages statically pre-rendered (export const prerender = true on just those two files, per Chapter 10) while leaving only subscribe.ts dynamic — the right call depends on how much of the site is genuinely static versus genuinely dynamic, exactly the judgment call Chapter 10 raised.
Every Chapter's Own Contribution
| Piece | Chapter(s) |
|---|---|
| Zero-JS static rendering as the baseline | 1 |
| Frontmatter, props, markup expressions | 2 |
Blog index/detail routing, getStaticPaths() | 3 |
BaseLayout, <slot /> | 4 |
| The blog Content Collection & schema | 5 |
| Scoped and global dark-theme styling | 6 |
client:visible on the newsletter island | 7 |
| The React integration itself | 8 |
The /api/subscribe POST endpoint | 9 |
output: 'server' + the Node adapter | 10 |
| The MDX post with its own embedded island | 11 |
Coding Challenges
Build this capstone's own blog collection, BaseLayout, index page, and [slug].astro route from scratch, with at least two real Markdown posts.
📄 View solutionAdd the NewsletterSignup island with client:visible, and the /api/subscribe endpoint it posts to, confirming a real submission returns { status: 'ok' }.
📄 View solutionRefine the project by marking the blog index and [slug] pages export const prerender = true while leaving subscribe.ts dynamic, and confirm the site still builds correctly with this mixed approach.
📄 View solutionChapter 12 Quick Reference
- Content Collections + Zod — one schema, mixed
.md/.mdxentries getStaticPaths()+props— dynamic routing without a second lookupclient:visible— an island whose JS never downloads until scrolled into view- A real POST endpoint — the concrete reason a project needs server output, not static
- Per-page
prerender— the finer-grained alternative to an all-or-nothing rendering mode
★ Astro Course Complete — 12 / 12 chapters
From zero-JS-by-default and the islands architecture through Content Collections, styling, cross-framework composition, data fetching, and rendering modes. Chapters 3 and 5 in particular — arbitrary-depth routing and structured content — are the direct foundation a future Website Rebuild with Astro course can now build on.