# Astro

A portfolio with Astro 7: components, layouts, routing, Markdown in content collections, optimised images, islands and a deploy.

Multimedia & Creative Technology · 3.1 Extra Webdev · Self-study · version 0.1.0 · 2026-10-14

Tags: astro, html, markdown, javascript, images

Slides: <https://slides.mctantwerp.be/en/extra-webdev/05-astro>

<!-- slide 2 -->

## After this lesson you can

- Explain what Astro sends to the browser, and when a site needs something else
- Build pages from .astro components with props, slots and scoped styles, in a shared layout
- Make a page per project with file-based routing and getStaticPaths
- Keep your projects in a content collection with a schema, and list and render them
- Serve optimised images with astro:assets, and add interactivity with a script tag or an island
- Build your site and put it online, with title, description and Open Graph tags

> **Explanation:** These are the parts you need for nearly every content site: a portfolio, a site for a band, a small business or an event. What this deck leaves out: logins, a database and server islands. Those belong in a framework with a backend, or in a later step once the static site stands.

<!-- slide 3 -->

## Agenda

1. What Astro is
2. A new project
3. Astro components
4. Layouts and routing
5. Markdown and collections
6. Images
7. Interactivity
8. Build and deploy
9. Polish and pitfalls

> **Explanation:** Chapters 1 to 4 are the basics you need for any Astro site. Chapter 5 on collections is the heart of a portfolio, do not skip it. The assignments sit after chapters 4, 6 and 9, each one builds on the previous one.

<!-- slide 4 -->

## 1. What Astro is

> **Explanation:** Before you write a line, it helps to know what Astro does with your code. It explains why some things you know from plain HTML just work, and why a button in a component sometimes does nothing.

<!-- slide 5 -->

**Astro builds HTML, and ships JavaScript only where you ask for it.**

You write components, Astro renders them to plain HTML files when you build. A page without interactive parts reaches the browser without a single line of JavaScript.

> **Explanation:** Astro started in 2021 and is open source. Since early 2026 the team works at Cloudflare, the project stays MIT licensed and works on every host. Version 7 came out in June 2026 with a new compiler written in Rust, which makes builds faster and is stricter about broken HTML. More on that in the last chapter.

<!-- slide 6 -->

### Zero JavaScript by default

*src/pages/index.astro*

```astro
---
const projects = ['Bakery rebrand', 'Festival app']
---
<h1>Noor Vermeulen</h1>
<ul>
  {projects.map((p) => <li>{p}</li>)}
</ul>
```

*dist/index.html*

```html
<h1>Noor Vermeulen</h1>
<ul>
  <li>Bakery rebrand</li>
  <li>Festival app</li>
</ul>
```

Left what you write, right what ends up on the server after `npm run build`. The `map()` ran once, on your laptop. The browser gets finished HTML, and no script tag.

> **Explanation:** The code between the --- fences runs on your laptop during the build, or on the server. The browser never sees it, it only gets the result. That is the big difference with a React app, where the browser downloads the components and builds the page itself. For a portfolio that means a fast first load, also on a cheap phone on 4G, and a page search engines read without running anything.

<!-- slide 7 -->

### Islands

Header with dark mode toggleclient:load

Intro text and a photo

Project

Project

Project

Contact formclient:visible

Footer

The grey blocks are static HTML. The two coloured blocks are islands: components that get their own JavaScript and run on their own.

The directive says when that JavaScript loads. The toggle right away, the form only when you scroll to it. More in [Islands architecture](https://docs.astro.build/en/concepts/islands/).

> **Explanation:** The name comes from an article by Jason Miller in 2020: a page is a sea of static HTML with small islands of interactivity in it. Every island loads on its own, so a heavy contact form at the bottom does not slow down the menu at the top. Most portfolio pages have zero or one island. When you notice every part of a page is an island, Astro is probably the wrong tool.

<!-- slide 8 -->

### Good fit, bad fit

#### Astro fits

- Your portfolio, with a page per project
- A site for a client: a few pages, news, a contact form
- A blog or documentation, written in Markdown
- Anything that has to load fast on a cheap phone

#### Pick something else

- An app behind a login, where every click changes the screen: a planner, a dashboard
- Realtime work together, like a chat or a shared whiteboard
- A webshop with accounts and stock: Shopify, or Laravel with a payment provider

> **Explanation:** Content sites are where Astro shines: pages that look the same for every visitor and change when you publish, not on every click. For an app behind a login, like a planner or a dashboard, you end up making every part an island, and then a framework that is built for apps works better. That is what Laravel with Inertia does in other courses. A client who wants to edit texts without git can still use Astro, with a headless CMS as the source of a collection.

<!-- slide 9 -->

### A page with a header, ten project cards and a footer, all .astro components, and no client: directive anywhere. How much JavaScript does the browser download for it?

- A. None
- B. The Astro runtime, about 10 KB
- C. A bit per component, to render it
- D. React, which Astro uses under the hood

**Answer:** A. None

Astro components render to HTML during the build and leave nothing behind. There is no runtime that has to start in the browser. JavaScript only shows up for a script tag you write yourself, or for an island with a client directive. Astro does not use React under the hood: it only loads React when you add it yourself.

> **Explanation:** This is the number that surprises people coming from React or Vue: a page that is only made of .astro components really sends nothing. Open the network tab of your own page later and filter on JS to check it.

<!-- slide 10 -->

## 2. A new project

> **Explanation:** Astro has a wizard that sets up a project in a minute. This chapter is about what it makes and where things go. Getting the folders right from the start saves you moving files later.

<!-- slide 11 -->

### npm create astro

```console
$ npm create astro@latest
astro   Launch sequence initiated.
 dir     Where should we create your new project?  ./portfolio
 tmpl    How would you like to start your new project?  Use minimal (empty) template
 deps    Install dependencies?  Yes
 git     Initialize a new git repository?  Yes
$ cd portfolio && npm run dev
astro  v7.3.8 ready in 312 ms
 ┃ Local    http://localhost:4321/
```

Needs Node 22.12 or newer. Save a file and the browser updates on its own. Install the [Astro extension](https://marketplace.visualstudio.com/items?itemName=astro-build.astro-vscode) for VS Code, or .astro files stay grey. See [Install Astro](https://docs.astro.build/en/install-and-setup/).

> **Explanation:** The basics template gives you a welcome page and a layout to look at, minimal gives you one empty page. For your portfolio, minimal is cleaner: you build everything yourself and know what every file does. Port 4321 is the default; when it is taken, Astro takes the next free one and tells you in the terminal. If npm create fails on an old Node version, install the LTS version from nodejs.org or through a version manager like fnm.

<!-- slide 12 -->

### The project structure

```txt
portfolio/
├── public/
│   ├── favicon.svg
│   └── cv.pdf
├── src/
│   ├── components/
│   ├── layouts/
│   ├── pages/
│   │   └── index.astro
│   └── styles/
├── astro.config.mjs
└── package.json
```

`src/pages` is the only folder Astro reserves: every file in it becomes a url.

`public` is copied to your site untouched. Favicon, `robots.txt`, a PDF.

`astro.config.mjs` holds your integrations and settings, like the url of your site.

More in [Project structure](https://docs.astro.build/en/basics/project-structure/).

> **Explanation:** components, layouts and styles are conventions: Astro does not care what you call them, but every tutorial and every colleague expects them there. Stick to them. A file in public is served as it is, at the root of your site: public/cv.pdf becomes /cv.pdf. Images you show on a page go in src, so Astro can optimise them; chapter 6 explains why.

<!-- slide 13 -->

## 3. Astro components

> **Explanation:** Everything you build in Astro is a component: a page is a component, a layout is a component, a card is a component. Once you can write one, with props and a slot, you can build the whole site.

<!-- slide 14 -->

### An .astro file

*src/pages/index.astro*

```astro
---
const name = 'Noor Vermeulen'
const skills = ['HTML', 'CSS', 'Astro']
---
<html lang="en">
  <head><title>{name}</title></head>
  <body>
    <h1>Hi, I am {name}</h1>
    <ul>
      {skills.map((skill) => <li>{skill}</li>)}
    </ul>
  </body>
</html>
```

1. The component script, between two `---` fences. JavaScript or TypeScript that runs on build, never in the browser.
2. The template: plain HTML. Anything that is valid HTML is valid here.
3. Curly braces put a value from the script into the HTML.
4. `map()` turns an array into a list of elements, like in JSX.

> **Explanation:** The component script can import other components, read files and fetch data with await, at the top level, without wrapping it in a function. It runs once per build for a static page. That also means a console.log in the script shows up in your terminal, not in the console of the browser: a common moment of confusion. Want code that runs in the browser? That is a script tag, in chapter 7.

<!-- slide 15 -->

### Expressions

```astro
---
const available = true
const links = [
  { label: 'CV', href: '/cv.pdf' },
  { label: 'Mail', href: 'mailto:noor@example.com' },
]
---
{available && <p class="badge">Open for freelance work</p>}

<ul>
  {links.map((l) => <li><a href={l.href}>{l.label}</a></li>)}
</ul>
```

1. With `&&` an element only shows up when the value is true. A ternary `? :` gives an alternative.
2. An attribute that takes a value from the script gets braces instead of quotes: `href={l.href}`.

> **Explanation:** The syntax looks like JSX, the templates of React, but it is closer to HTML: you write class and not className, for and not htmlFor, and a style attribute can be a plain string. If you have used the template literals from year 2, the curly braces work the same way. Need raw HTML from a string, for example from a CMS? set:html does that, but only with HTML you trust, because Astro does not escape it.

<!-- slide 16 -->

### Props

*src/components/ProjectCard.astro*

```astro
---
interface Props {
  title: string
  year: number
  href: string
}

const { title, year, href } = Astro.props
---
<article class="card">
  <h3><a href={href}>{title}</a></h3>
  <p>{year}</p>
</article>
```

1. The props this card expects, with their types.
2. `Astro.props` holds what the page passed. Destructuring takes the three values out.
3. The template uses them like any other variable.

> **Explanation:** The interface is TypeScript, and optional: without it, the component works the same. With it, VS Code warns you when you forget a prop or pass a string where a number belongs, and npx astro check finds those mistakes in the whole project. A question mark makes a prop optional, and you can give it a default when you destructure: const { year = 2026 } = Astro.props.

<!-- slide 17 -->

### Using a component

*src/pages/projects.astro*

```astro
---
import ProjectCard from '../components/ProjectCard.astro'
---
<h1>Projects</h1>
<section class="projects">
  <ProjectCard title="Bakery rebrand" year={2025} href="/projects/bakery-rebrand" />
  <ProjectCard title="Festival app" year={2026} href="/projects/festival-app" />
</section>
```

Import it in the script, use it as a tag in the template. Props are attributes: a string between quotes, anything else between braces, like `year={2025}`. See [Astro components](https://docs.astro.build/en/basics/astro-components/).

> **Explanation:** The import path is relative to the file you are in, that is why there are two dots. Component names start with a capital letter, so Astro knows ProjectCard is a component and not an HTML element. Later in this deck the data comes from Markdown files instead of being typed in the page, but the card stays the same.

<!-- slide 18 -->

### Slots

*src/components/Section.astro*

```astro
---
const { title } = Astro.props
---
<section>
  <h2>{title}</h2>
  <slot />
  <footer>
    <slot name="footer" />
  </footer>
</section>
```

*src/pages/index.astro*

```astro
<Section title="Selected work">
  <p>Three projects from 2025.</p>

  <a slot="footer" href="/projects">
    All projects
  </a>
</Section>
```

The paragraph lands in `<slot />`, the link in the slot named footer. That way a component wraps content it does not know in advance.

> **Explanation:** Content you put between the tags of a component goes where the slot is. Without a slot element in the component, that content is simply dropped, without a warning. A slot can have fallback content between its tags, shown when the page passes nothing: slot with 'No projects yet' inside. With Astro.slots.has('footer') you check whether a named slot got something, handy to leave out an empty footer.

<!-- slide 19 -->

### Scoped styles

*ProjectCard.astro*

```astro
<article class="card">
  <h3>{title}</h3>
</article>

<style>
  .card { padding: 1.5rem; }
  h3 { margin: 0; }
</style>
```

*What Astro makes of it*

```css
.card[data-astro-cid-x7k2] {
  padding: 1.5rem;
}
h3[data-astro-cid-x7k2] {
  margin: 0;
}
```

A style tag in a component only applies to that component. Astro gives its elements an attribute and adds it to every selector, so `h3` touches no other heading. See [Styles and CSS](https://docs.astro.build/en/guides/styling/).

> **Explanation:** A parent cannot style the inside of a child component with scoped styles, and a child does not inherit them. Need a rule for the whole site, like fonts and colours? Put it in src/styles/global.css and import that in your layout (next chapter). In between there is :global(): article :global(h2) styles every h2 inside the article, also when it comes from a slot or from Markdown. That last one you need for the body of a project page.

<!-- slide 20 -->

### Tailwind

```console
$ npx astro add tailwind
 Installing @tailwindcss/vite tailwindcss
 Creating src/styles/global.css
 ✔ Success! Configuration up-to-date.
```

*src/styles/global.css*

```css
@import "tailwindcss";
```

```astro
<article class="rounded-xl border-2 p-6 hover:border-black">
```

One command, then import `global.css` in your layout and the classes work everywhere. Details in [Styles and CSS: Tailwind](https://docs.astro.build/en/guides/styling/#tailwind).

**[Tailwind CSS](https://slides.mctantwerp.be/en/extra-webdev/03-tailwind)**: Everything about the classes themselves.

> **Explanation:** astro add installs the official Vite plugin of Tailwind 4, @tailwindcss/vite, and registers it in astro.config.mjs. Older tutorials use @astrojs/tailwind: that integration was for Tailwind 3 and is deprecated, do not install it. Tailwind and scoped styles mix fine, many people use Tailwind for layout and spacing and a style tag for the odd exception.

<!-- slide 21 -->

### ProjectCard.astro has a style tag with h3 { color: red }. Below the card, Section has its own h3. Which headings turn red?

- A. Only the h3 inside ProjectCard
- B. Every h3 on that page
- C. Every h3 on the whole site
- D. Both, they share a page

**Answer:** A. Only the h3 inside ProjectCard

Astro adds a data-astro-cid attribute to the elements of ProjectCard and to its selectors, so the rule only matches its own h3. The h3 in Section has a different attribute. Want every h3 red? Then the rule goes in a global stylesheet or in a style tag with is:global.

> **Explanation:** A scoped style also does not reach HTML that comes from Markdown through Content, because those elements do not get the attribute either. That is the moment for :global() or a global stylesheet.

<!-- slide 22 -->

## 4. Layouts and routing

> **Explanation:** A page in Astro is a component in src/pages, and the html, head and body they share live in one layout. Get this right and adding a page is a file of ten lines.

<!-- slide 23 -->

### A layout

*src/layouts/BaseLayout.astro*

```astro
---
import '../styles/global.css'
const { title } = Astro.props
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title} | Noor Vermeulen</title>
  </head>
  <body><slot /></body>
</html>
```

1. The global stylesheet, imported once. Every page in this layout gets it.
2. The head every page needs, with a title per page.
3. The slot: this is where each page puts its own content.

> **Explanation:** The two meta tags are not optional: without charset, accents break, and without viewport, your site shows up zoomed out on a phone. Add a Props interface with title and an optional description, like on the Props slide. The Header and Footer components go in the body, around the slot, so every page gets them for free.

<!-- slide 24 -->

### A page in a layout

*src/pages/about.astro*

```astro
---
import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout title="About">
  <h1>About me</h1>
  <p>Third year Multimedia & Creative Technology in Antwerp.</p>
</BaseLayout>
```

The page only holds what is different. The rest comes from the layout, so a change in the head is one file instead of ten. See [Layouts](https://docs.astro.build/en/basics/layouts/).

> **Explanation:** Layouts can be nested: a ProjectLayout that uses BaseLayout and adds a back link and the meta data of a project. Keep it to two levels, or you spend your time hunting where a tag comes from. See Layouts in the docs for nesting and for Markdown layouts.

<!-- slide 25 -->

### File-based routing

| File in src/pages | Url |
| --- | --- |
| `index.astro` | / |
| `about.astro` | /about |
| `projects/index.astro` | /projects |
| `projects/[id].astro` | /projects/bakery-rebrand, /projects/festival-app, one per project |
| `404.astro` | The page for a url that does not exist |
| `_draft.astro` | None: a name with an underscore stays out of the site |

No routes file: the path of the file is the url. See [Routing](https://docs.astro.build/en/guides/routing/).

> **Explanation:** index.astro is the page of its folder: projects/index.astro is /projects. Two files for the same url is a mistake Astro warns you about. The underscore is handy for a page you are still working on, or for a helper file next to your pages. A link between your pages is a plain a tag with href, no special component needed.

<!-- slide 26 -->

### Dynamic routes

*src/pages/projects/\[id\].astro*

```astro
---
export function getStaticPaths() {
  return [
    { params: { id: 'bakery-rebrand' }, props: { year: 2025 } },
    { params: { id: 'festival-app' }, props: { year: 2026 } },
  ]
}

const { id } = Astro.params
const { year } = Astro.props
---
<h1>{id}</h1>
<p>Made in {year}</p>
```

1. The brackets make it dynamic. `getStaticPaths` lists every page to build: the `params` fill the url, `props` go to the page.
2. `Astro.params` holds the part of the url, `Astro.props` the extra data.
3. One template, as many pages as there are items.

> **Explanation:** getStaticPaths runs once, at build time, and Astro writes one HTML file per item: dist/projects/bakery-rebrand/index.html. A url that is not in the list gives a 404. The name between the brackets has to match the key in params, so [id].astro needs params: { id }. In chapter 5 the list comes from your Markdown files instead of being typed by hand.

<!-- slide 27 -->

### Your header with your name and the menu has to be on every page. Where does it go?

- A. In src/components/Header.astro, used in the layout
- B. In src/pages/header.astro
- C. Copied into every page, so each page can change it
- D. In public/header.html

**Answer:** A. In src/components/Header.astro, used in the layout

A component, used once in the layout, so every page gets it and a change is one file. In src/pages it would become a page of its own at /header. Copying it means changing ten files for one new menu item. public is copied untouched: Astro does not process anything in there, and you cannot import it as a component.

> **Explanation:** Every file in src/pages becomes a url, also when you did not mean it as a page. A rule of thumb: pages in src/pages, the frame of every page in src/layouts, and everything you use more than once or want to keep apart in src/components.

<!-- slide 28 -->

### Task: Start your portfolio

Stuck? See the slides A layout and File-based routing, and [Layouts](https://docs.astro.build/en/basics/layouts/) in the docs.

1. Create a project with npm create astro@latest and push it to a new repository on GitHub
2. Make BaseLayout.astro, a Header component with your name and a menu, and a global.css (or Tailwind)
3. Make the pages /, /about and /projects, all three in the layout
4. Make ProjectCard.astro with props, and show three cards on /projects
5. Done when npm run build ends without errors and every link in the menu works in npm run preview

> **Explanation:** Start with the minimal template, so every file is one you wrote yourself. Keep the data of the three projects in an array in the script of projects.astro for now, the next assignment moves it to Markdown. Commit after every step that works: when something breaks, git diff shows what changed.

<!-- slide 29 -->

## 5. Markdown and collections

> **Explanation:** Typing your projects into an array works for three projects. For a real portfolio you want one file per project, in Markdown, checked by a schema. That is a content collection, and it is the feature that makes Astro so good for content sites.

<!-- slide 30 -->

### Markdown pages

*src/pages/colophon.md*

```md
---
layout: ../layouts/MarkdownLayout.astro
title: Colophon
---

# How this site is made

Built with **Astro**, hosted on Netlify.
Fonts by [Google Fonts](https://fonts.google.com).
```

A `.md` file in `src/pages` becomes a page too. The layout gets the frontmatter as `Astro.props.frontmatter` and the text in its slot.

**[Markdown](https://slides.mctantwerp.be/en/general/01-markdown)**: The syntax itself: headings, lists, links and code.

> **Explanation:** Markdown pages are fine for one long text like an about page or a colophon. For content that repeats, like projects or blog posts, use a collection instead: the docs say themselves that the layout property in frontmatter is not meant for collections. Since Astro 7, Markdown is processed by Sätteri, a fast processor written in Rust. If you find a tutorial with remark or rehype plugins, you need the older unified processor for those.

<!-- slide 31 -->

### MDX

```console
$ npx astro add mdx
```

*src/content/projects/festival-app.mdx*

```md
import Video from '../../components/Video.astro'

The app shows the timetable of Paradise City, also offline.

<Video src="https://static.example.com/festival-app.mp4" />
```

Markdown in which you import and use components. See [@astrojs/mdx](https://docs.astro.build/en/guides/integrations-guide/mdx/).

> **Explanation:** MDX is handy for a project page with a video embed, a before and after slider or a gallery in the middle of the text. Do not reach for it by default: a .md file is easier to read and to edit, also for a client. MDX is stricter too: a stray curly brace or an unclosed tag breaks the build, because MDX reads it as code.

<!-- slide 32 -->

**A collection is content with a contract.**

One file per project, in one folder, and a schema that says which fields every project has. Astro checks every file against it on build, and a missing title stops the build instead of reaching your site.

> **Explanation:** Before collections, every tutorial read Markdown with Astro.glob, and a typo in a date only showed up on the live site. A collection gives every entry a type, so VS Code completes project.data.title for you. Since Astro 5 this is called the content layer: the same API, whether the entries come from Markdown, a JSON file or a CMS.

<!-- slide 33 -->

### One file per project

*src/content/projects/bakery-rebrand.md*

```md
---
title: Bakery rebrand
year: 2025
client: Bakkerij Mertens
tags: [branding, web]
draft: false
---

A new identity and a webshop for a
family bakery in Berchem.
```

The frontmatter between the dashes is data: the fields every project has.

Below it the story of the project, in Markdown, as long as you want.

`src/content` is a convention. The config on the next slide says where to look.

> **Explanation:** The frontmatter is YAML: a key, a colon, a space and the value. Strings without special characters need no quotes, a colon in a title does. Lists go between square brackets or one per line with a dash. The file name becomes the id of the entry, and later the url: bakery-rebrand.md ends up at /projects/bakery-rebrand.

<!-- slide 34 -->

### The collection config

```ts
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'

const projects = defineCollection({
  loader: glob({ pattern: '*.md', base: './src/content/projects' }),
  schema: z.object({
    title: z.string(),
    year: z.number(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
})

export const collections = { projects }
```

1. The file is `src/content.config.ts`. Three imports, `z` comes from `astro/zod`.
2. The loader: every `.md` file in that folder is a project.
3. The schema: which fields, of which type.
4. Export every collection by name.

> **Explanation:** The file has to be src/content.config.ts, in the root of src. A file() loader reads one JSON or YAML file with a list instead, handy for testimonials or the logos of clients: file('src/data/clients.json'). For data from a CMS there are loaders on npm, or you write your own. Restart npm run dev when you change this file and nothing seems to happen.

<!-- slide 35 -->

### The schema, field by field

| In the schema | In the frontmatter |
| --- | --- |
| `z.string()` | `title: Bakery rebrand` |
| `z.number()` | `year: 2025`, without quotes |
| `z.coerce.date()` | `date: 2025-03-14`, becomes a Date |
| `z.enum(['web', 'print', 'motion'])` | Only one of these three |
| `z.string().optional()` | May be left out |
| `z.boolean().default(false)` | False when it is missing |

A file that does not match stops `npm run dev` and the build, with the file and the field in the error. The full API is on [zod.dev](https://zod.dev).

> **Explanation:** Fields that are not in the schema are dropped: add a client in the frontmatter but not in the schema, and project.data.client is undefined. Add it as z.string().optional(). Astro 6 moved to Zod 4: z.email() and z.url() instead of z.string().email(), and error instead of message for your own error text. Tutorials from before 2026 still use the old forms.

<!-- slide 36 -->

### getCollection

*src/pages/projects/index.astro*

```astro
---
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
import ProjectCard from '../../components/ProjectCard.astro'

const projects = (await getCollection('projects', ({ data }) => !data.draft))
  .sort((a, b) => b.data.year - a.data.year)
---
<BaseLayout title="Projects">
  {projects.map((project) => <ProjectCard project={project} />)}
</BaseLayout>
```

1. Every project that is not a draft.
2. Newest first: the order of a collection is not fixed.
3. The whole entry goes to the card as one prop.

> **Explanation:** The second argument of getCollection is a filter: here every project where draft is not true. The order of getCollection is not guaranteed, so always sort it yourself, or your newest project ends up somewhere in the middle after the next build. To get a single entry, use getEntry('projects', 'bakery-rebrand').

<!-- slide 37 -->

### The card takes an entry

*src/components/ProjectCard.astro*

```astro
---
import type { CollectionEntry } from 'astro:content'

interface Props {
  project: CollectionEntry<'projects'>
}
const { project } = Astro.props
const { title, year } = project.data
---
<article class="card">
  <h3><a href={`/projects/${project.id}`}>{title}</a></h3>
  <p>{year}</p>
</article>
```

An entry has an `id` (the file name without `.md`) and `data` (the checked frontmatter).

The link to the project page is built from the id.

> **Explanation:** CollectionEntry<'projects'> is the type of one project, with id, data and body. Thanks to the schema, VS Code knows that project.data.year is a number and project.data.client does not exist. Rename a field in the schema and every place that uses the old name turns red.

<!-- slide 38 -->

### A page per project

```astro
---
import { getCollection, render } from 'astro:content'
export async function getStaticPaths() {
  const projects = await getCollection('projects')
  return projects.map((project) => ({
    params: { id: project.id },
    props: { project },
  }))
}

const { project } = Astro.props
const { Content } = await render(project)
---
<h1>{project.data.title}</h1>
<Content />
```

1. In `src/pages/projects/[id].astro`: one page per project. The id fills the url, the entry goes along as a prop.
2. `render()` turns the Markdown body into a component.
3. The title from the frontmatter, then the body as HTML.

> **Explanation:** render() turns the Markdown body into a Content component, and also gives you the headings, handy for a table of contents. Style the HTML of Content with a global stylesheet or with :global() in the page, a scoped style does not reach it. Want a nicer url than the file name? Put slug: my-url in the frontmatter, the glob loader uses it as id.

<!-- slide 39 -->

### Older tutorials

| Old, before Astro 6 | Now |
| --- | --- |
| `src/content/config.ts` | `src/content.config.ts` |
| `type: 'content'`, no loader | `loader: glob({ ... })` |
| `import { z } from 'astro:content'` | `import { z } from 'astro/zod'` |
| `entry.slug` | `entry.id` |
| `await entry.render()` | `await render(entry)` |
| `Astro.glob()` | `import.meta.glob()` or a collection |
| `<ViewTransitions />` | `<ClientRouter />` |

Everything in the left column is removed. See [Upgrade to Astro v6](https://docs.astro.build/en/guides/upgrade-to/v6/).

> **Explanation:** Content collections changed twice: the content layer in Astro 5, and the end of the old API in Astro 6. AI tools and blog posts from 2023 and 2024 still write the old version, and it fails without a clear error. When a tutorial shows something from the left column, translate it. Run npx @astrojs/upgrade to update an existing project.

<!-- slide 40 -->

### A tutorial puts the schema in src/content/config.ts and uses post.slug. On Astro 7 your projects page stays empty. Why?

- A. That file is no longer read: use src/content.config.ts, a loader and id
- B. A legacy flag in astro.config.mjs is off
- C. Only the slug is wrong
- D. Collections need an adapter now

**Answer:** A. That file is no longer read: use src/content.config.ts, a loader and id

Astro 6 removed the old collections API together with the legacy flag. The config moved to src/content.config.ts, every collection needs a loader, and the slug became id. Collections that read files on build need no adapter: only live collections, which fetch on every request, do.

> **Explanation:** When a collection seems empty, check three things in this order: the name and place of the config file, the pattern and base of the loader, and whether the collection is in the export at the bottom.

<!-- slide 41 -->

## 6. Images

> **Explanation:** Images are the heaviest part of nearly every portfolio. A single photo straight from a camera or a phone weighs more than the rest of your site together. Astro can do the resizing and the converting for you, as long as you let it.

<!-- slide 42 -->

### The Image component

*src/pages/about.astro*

```astro
---
import { Image } from 'astro:assets'
import portrait from '../assets/noor.jpg'
---
<Image
  src={portrait}
  alt="Noor at her desk"
  width={800}
/>
```

*In dist*

```html
<img
  src="/_astro/noor.B3x9.webp"
  alt="Noor at her desk"
  width="800" height="1000"
  loading="lazy" decoding="async"
>
```

A 6 MB photo of 4000 pixels wide becomes a WebP of 800 pixels, with its height and lazy loading. `alt` is required. See [Images](https://docs.astro.build/en/guides/images/).

> **Explanation:** Image needs width and height to reserve the space before the image loads, so nothing jumps while the page loads (that jump is called layout shift, and Google counts it). For an imported image Astro reads them from the file. Picture does the same with several formats: formats={['avif', 'webp']} gives a picture element with a source per format, and the browser takes the first one it knows.

<!-- slide 43 -->

### A cover per project

*src/content.config.ts*

```ts
schema: ({ image }) => z.object({
  title: z.string(),
  year: z.number(),
  cover: image(),
  coverAlt: z.string(),
}),
```

*bakery-rebrand.md*

```md
---
title: Bakery rebrand
year: 2025
cover: ./bakery-rebrand.jpg
coverAlt: Paper bags with the new logo
---
```

*ProjectCard.astro*

```astro
<Image src={project.data.cover} alt={project.data.coverAlt} width={600} />
```

> **Explanation:** With image() the path in the frontmatter is relative to the Markdown file, so the cover sits next to the project. A typo in the file name now stops the build, instead of a broken image on your live site. Give each cover a coverAlt field in the schema too, and write what is in the picture, not 'cover of project'.

<!-- slide 44 -->

### Where an image lives

| Where | How you use it | Optimised |
| --- | --- | --- |
| `src/assets/` | Import it, pass it to `<Image>` | Yes: resized, WebP, a hash in the name |
| `src/content/projects/` | `image()` in the schema | Yes |
| `public/` | `<img src="/logo.svg">` | No, copied as it is |
| Another site | A url, with the domain in `image.domains` | Only when the domain is allowed |

**[Images and assets](https://slides.mctantwerp.be/en/general/06-images-and-assets)**: Formats, pixels and file sizes, and why a photo of 8 MB kills your site.

> **Explanation:** A remote image gets optimised too, once you allow its domain in astro.config.mjs under image.domains, for example the CDN of your CMS. Without that, Astro passes the url through as it is. Fonts, a favicon and files people download stay in public. Everything a visitor sees on a page goes through src.

<!-- slide 45 -->

### Which of these images ends up resized and converted to WebP?

- A. An Image with a photo imported from src/assets
- B. A plain img tag with src="/portrait.jpg", the file in public
- C. A JPG from public, linked in a Markdown file
- D. All three: Astro optimises every image on build

**Answer:** A. An Image with a photo imported from src/assets

Only images Astro processes get optimised: imported from src and shown with Image or Picture, or in a collection through image(). Everything in public is copied byte for byte, also when you link it from Markdown. That is fine for a favicon, and a waste for a portfolio photo.

> **Explanation:** The quickest test: open your built site, the network tab, filter on Img. Every image should be a webp or avif with a size close to how big it is on screen.

<!-- slide 46 -->

### Task: Your projects as a collection

Builds on your first assignment. Stuck? See the slides from The collection config to A page per project, and [Content collections](https://docs.astro.build/en/guides/content-collections/).

1. Add a projects collection in src/content.config.ts, with title, year, tags, draft, a cover and its alt text
2. Write at least three projects as Markdown files, each with its cover next to it
3. Show them on /projects, newest first, drafts left out, with the cover through Image
4. Make src/pages/projects/\[id\].astro that shows the title, the cover and the body of a project
5. Done when a project without a title stops the build with an error, and every card leads to its own page

> **Explanation:** Write the schema first, then one project, and run npm run dev: the errors tell you what is missing. Take the covers from your real projects and leave them big, Astro makes them small. For the tag list on a card, map over project.data.tags like the skills on the slide An .astro file.

<!-- slide 47 -->

## 7. Interactivity

> **Explanation:** Most of a portfolio needs no JavaScript at all. When it does, start with the smallest thing that works: a script tag. A framework component only pays off for a part with state that changes a lot, like a filter or a form with validation.

<!-- slide 48 -->

### A script tag

*src/components/Header.astro*

```astro
<button class="menu-toggle" aria-expanded="false">Menu</button>
<nav class="menu" hidden>...</nav>

<script>
  const button = document.querySelector('.menu-toggle')
  const menu = document.querySelector('.menu')
  button.addEventListener('click', () => {
    menu.hidden = !menu.hidden
    button.setAttribute('aria-expanded', String(!menu.hidden))
  })
</script>
```

1. Plain HTML, rendered on build like the rest.
2. The DOM code from year 2. Astro bundles it and runs it in the browser.

> **Explanation:** Astro processes this script like your other code: TypeScript works, you can import from npm, and it ends up as a module in a file of its own. Used on ten pages, the browser still loads it once. The script runs once per page load, after the HTML is there, so querySelector finds the button. Want the script left exactly as you wrote it, for example an analytics snippet? Add is:inline.

<!-- slide 49 -->

### Islands with a framework

```console
$ npx astro add svelte
 Installing @astrojs/svelte svelte
 ✔ Success! Configuration up-to-date.
```

A framework component keeps state: values that change, and the HTML follows on its own. You write less DOM code than with a script tag once a part has more than one or two things that change.

Astro supports React, Preact, Svelte, Vue and Solid. See [Front-end frameworks](https://docs.astro.build/en/guides/framework-components/).

> **Explanation:** Svelte is used here because it reads like HTML with a bit of JavaScript, so you can follow it without learning a framework first. React, Preact, Vue and Solid work the same way in Astro: npx astro add react, and the island part is identical. You can even mix them on one page, though you rarely should.

<!-- slide 50 -->

### A counter in Svelte

*src/components/Likes.svelte*

```svelte
<script>
  let { start = 0 } = $props()
  let count = $state(start)
</script>

<button onclick={() => count++}>
  ♥ {count}
</button>
```

1. The props, with a default value.
2. `$state` makes `count` reactive: when it changes, the HTML changes along.
3. A click adds one. No `querySelector`, no `textContent`.

> **Explanation:** $state and $props are runes, the way Svelte 5 marks reactive values. The rest is HTML. A like counter without a database resets on every reload, so this is a demo of the mechanism, not a feature for your portfolio. A tag filter on your projects page is a better first island: the state is the selected tag, and the list follows.

<!-- slide 51 -->

### Client directives

```astro
<Likes start={12} client:visible />
```

| Directive | Loads the JavaScript |
| --- | --- |
| `client:load` | Right away, with the page. For what is needed at once, at the top |
| `client:idle` | When the browser has nothing else to do |
| `client:visible` | When it scrolls into view. Your default further down a page |
| `client:media="(max-width: 50em)"` | When the media query matches |
| `client:only="svelte"` | Only in the browser, no HTML from the server |

See [Client directives](https://docs.astro.build/en/reference/directives-reference/#client-directives).

> **Explanation:** Without a directive the component still renders, as HTML, on build: you see the button with 0, but clicking does nothing. With client:only the server renders nothing, so put fallback content in a slot named fallback, or the spot stays empty until the JavaScript arrives. client:visible takes rootMargin to start a little before the island scrolls in.

<!-- slide 52 -->

**No directive, no JavaScript.**

Without a client directive, a Svelte or React component is rendered to HTML on build and sent without its JavaScript. The button shows up, clicking it does nothing, and the console stays empty.

> **Explanation:** The Inspect app of the dev toolbar shows every island on the page with its directive and its props. A component that is missing from that list got no directive. Note that a directive only works on a framework component: an .astro component cannot be hydrated, it uses a script tag instead.

<!-- slide 53 -->

### Which directive

**1. A dark mode toggle in the header, visible as soon as the page opens.**

- **client:load** ✓
- client:idle
- client:visible
- No directive

It is at the top and people click it right away. client:visible would load it at once too, but load says what you mean.

**2. A contact form with validation, at the bottom of your home page.**

- client:load
- **client:visible** ✓
- client:only="svelte"
- No directive

Most visitors never scroll that far. client:visible only loads it for those who do.

**3. A Svelte component that shows your skills as a row of tags. Nothing to click.**

- client:load
- client:idle
- client:visible
- **No directive** ✓

Nothing changes after it renders, so the HTML from the build is enough. Better still: make it an .astro component.

**4. A slide-in menu that only exists on screens narrower than 50em.**

- client:load
- **client:media** ✓
- client:visible
- client:idle

client:media with that query: on a laptop the JavaScript never loads.

**5. A component that reads localStorage while it renders, and crashes on build.**

- client:load
- **client:only="svelte"** ✓
- client:idle
- No directive

localStorage only exists in the browser. client:only skips the render on build, so nothing crashes. Give it fallback content.

> **Explanation:** The question every time: when does a visitor need this part to work? At once, at some point, only on some screens, or never. The later the JavaScript loads, the faster the rest of the page is ready.

<!-- slide 54 -->

### A page passes onSelect={(id) => console.log(id)} to a Svelte island with client:load. What happens?

- A. The island never gets it: island props must be serializable
- B. Astro sends the function along
- C. The function becomes a string the browser evals
- D. Only works with client:only

**Answer:** A. The island never gets it: island props must be serializable

The page renders on build, the island runs later in the browser. In between, Astro writes its props into the HTML as data: strings, numbers, arrays, plain objects, Date, Map, Set and URL. A function cannot be written down like that. Put the logic inside the island, or let the island and the page talk through an event.

> **Explanation:** The other way round does work: an island can call a function it imports itself, or send a CustomEvent that a script tag on the page listens to. Children work too: what you put between the tags of an island arrives as a slot (Svelte, Vue) or as children (React, Preact).

<!-- slide 55 -->

## 8. Build and deploy

> **Explanation:** By now your site works on localhost. This chapter is about the step to a real url: what npm run build makes, when you need a server, and where you can host a static site for free.

<!-- slide 56 -->

### npm run build

```console
$ npm run build
generating static routes
 ▶ src/pages/index.astro
 ▶ src/pages/projects/[id].astro
   ├─ /projects/bakery-rebrand/index.html
   └─ /projects/festival-app/index.html
✓ Completed. 6 page(s) built in 1.84s
$ npm run preview
```

Output shortened. The result is a `dist` folder with an HTML file per page, CSS, the optimised images and the JavaScript of your islands. Any web server can serve it.

> **Explanation:** dist is what you put online, nothing else. It is in .gitignore for a reason: the host builds it again from your source. npm run preview serves dist like a host would, so you catch a broken link or a missing image before your client does. Run it before every deploy.

<!-- slide 57 -->

### Static or on demand

#### Static, the default

- Every page is built once, on npm run build
- Free hosting on Netlify, Vercel, Cloudflare or GitHub Pages
- Nothing to hack or to keep running
- New content means a new build

#### On demand, with an adapter

- A page or endpoint is rendered on every request
- Can read cookies, headers and a POST
- Needs a host that runs code: an adapter
- Mix it: only the pages that need it

> **Explanation:** A portfolio is nearly always fully static. The contact form is the usual exception: the form needs somewhere to send its data. A form service like Netlify Forms or Formspree solves that without a server of your own. Only when you want your own code to handle the request, you need on-demand rendering, and an adapter.

<!-- slide 58 -->

### An adapter

```console
$ npx astro add netlify
```

The adapter makes the server part of your site run on that host. Then one line puts a route on demand. See [On-demand rendering](https://docs.astro.build/en/guides/on-demand-rendering/).

*src/pages/api/contact.ts*

```ts
import type { APIRoute } from 'astro'

export const prerender = false

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData()
  // send form.get('email') to a mail API
  return redirect('/thanks')
}
```

> **Explanation:** With output: 'server' in astro.config.mjs you turn it around: every page on demand, and prerender = true for the ones that stay static. For a portfolio, stick to the default and set prerender = false on the one or two routes that need it. The adapters for Netlify, Vercel, Cloudflare and Node are official, others come from the community.

<!-- slide 59 -->

### Deploy a static site

| Host | Build command | Folder | Adapter |
| --- | --- | --- | --- |
| [Netlify](https://docs.astro.build/en/guides/deploy/netlify/) | `npm run build` | `dist` | No |
| [Vercel](https://docs.astro.build/en/guides/deploy/vercel/) | `npm run build` | `dist` | No |
| [GitHub Pages](https://docs.astro.build/en/guides/deploy/github/) | A GitHub Action | Done by the action | No |

A static site needs no adapter, anywhere. Import the repository on the site of the host, check the command and the folder, deploy.

**[Deploy your site](https://slides.mctantwerp.be/en/extra-webdev/04-deploy)**: Hosts, build settings and your own domain.

> **Explanation:** All three connect to your GitHub repository and build on every push to main, and give you a preview url for a pull request. Netlify and Vercel recognise Astro on their own. Do you want your own domain? Every host explains how to point it there, it is a few DNS records. If the build fails on the host but works locally, check the Node version first.

<!-- slide 60 -->

### GitHub Pages

*astro.config.mjs*

```js
import { defineConfig } from 'astro/config'

export default defineConfig({
  site: 'https://noorv.github.io',
  base: '/my-portfolio',
})
```

Your site lives in a subfolder named after the repository, so Astro needs to know that `base`. The workflow and the settings are in [Deploy to GitHub Pages](https://docs.astro.build/en/guides/deploy/github/).

> **Explanation:** The workflow file comes from the docs: copy it to .github/workflows/deploy.yml, it uses the action withastro/action. Then Settings, Pages, Source: GitHub Actions. With a base, links you write yourself need it too: href='/my-portfolio/about', or build them with import.meta.env.BASE_URL. Name your repository username.github.io and you can leave base out.

<!-- slide 61 -->

## 9. Polish and pitfalls

> **Explanation:** The site works and it is online. These last slides make it nicer to share, to navigate and to debug, and list the mistakes nearly everybody makes once.

<!-- slide 62 -->

### SEO in the head

*src/components/Seo.astro*

```astro
---
const { title, description } = Astro.props
const url = new URL(Astro.url.pathname, Astro.site)
const image = new URL('/og.jpg', Astro.site)
---
<title>{title} | Noor Vermeulen</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
```

1. Full urls, built from `site` in the config.
2. The title and the description in Google.
3. The card in a LinkedIn post or a Teams chat.

> **Explanation:** The og:image needs a full url, that is why Astro.site is there: set site in astro.config.mjs. Make the og image 1200 by 630 pixels, LinkedIn and Teams crop it to that. For a sitemap, run npx astro add sitemap: it builds sitemap-index.xml from your pages, as long as site is set. Check your tags by pasting your url in the LinkedIn Post Inspector.

<!-- slide 63 -->

### View transitions

#### In the browser, with CSS

*global.css*

```css
@view-transition {
  navigation: auto;
}
```

#### With ClientRouter

*BaseLayout.astro*

```astro
---
import { ClientRouter } from 'astro:transitions'
---
<head>
  <ClientRouter />
</head>
```

> **Explanation:** The CSS version is the simplest: two lines, and browsers that support it animate between pages while it stays a normal site, and the others simply navigate without animation. ClientRouter goes further: it swaps pages itself without a full reload, can keep an island alive with transition:persist, and has a fallback for every browser. transition:name pairs an element on two pages, like the cover on the card and on the project page.

<!-- slide 64 -->

### ClientRouter and your scripts

*src/components/Header.astro*

```astro
<script>
  document.addEventListener('astro:page-load', () => {
    const button = document.querySelector('.menu-toggle')
    const menu = document.querySelector('.menu')
    button.addEventListener('click', () => (menu.hidden = !menu.hidden))
  })
</script>
```

1. With ClientRouter a script runs once, not after every navigation. `astro:page-load` fires every time a page is shown.
2. The setup goes inside, so the new button of the new page gets its listener.

> **Explanation:** This is the classic bug after turning on ClientRouter: the menu works on the first page, and after one click on a link it is dead. The page did not really reload, so your script did not run again. astro:page-load fires on the first load and after every navigation. With the CSS version of view transitions this problem does not exist, because every page is a real page load. See View transitions in the docs.

<!-- slide 65 -->

### The dev toolbar

- A bar at the bottom of every page in npm run dev, never on your live site
- Inspect shows every island with its directive and its props
- Audit checks the page for common accessibility and performance problems, and points at the element
- The Astro menu copies the output of astro info, handy when you ask for help

> [!TIP]
> Run Audit on every page before you hand in or deploy. A red dot means it found something. See [Dev toolbar](https://docs.astro.build/en/guides/dev-toolbar/).

> **Explanation:** Audit catches the classics: an image without alt, a link without text, a button without a name. It is no replacement for Lighthouse or a screen reader, but it costs nothing and it sits on every page you open. Bothered by it? astro preferences disable devToolbar switches it off for you, without touching the project.

<!-- slide 66 -->

### Common mistakes

| What you see | Why, and the fix |
| --- | --- |
| A button in a Svelte or React component does nothing | No client directive. Add one. |
| `document is not defined` on build | Browser code in the component script. Move it to a script tag. |
| A field from the frontmatter is undefined | It is not in the schema, so Zod drops it. Add it. |
| Projects in a random order | getCollection does not sort. Sort it yourself. |
| Works locally, links break on GitHub Pages | base is missing in the config or in your links. |
| Your photos still weigh megabytes | They are in `public`. Move them to `src` and use Image. |

> **Explanation:** The document is not defined error deserves a second look: everything in the --- fences runs on build, in Node, where there is no window and no document. Move that code to a script tag, or into an island. When you ask for help, paste the full error and the output of npx astro info: version numbers solve half of the questions.

<!-- slide 67 -->

### Stricter since Astro 7

*An error now*

```astro
<p>
  <div>Selected work</div>
</p>
```

*CVMail on the screen*

```astro
<a href="/cv.pdf">CV</a>
<a href="mailto:noor@example.com">Mail</a>
```

A `div` inside a `p` or a tag you forgot to close stops the build. And a line break between two links no longer becomes a space: add `{' '}` or a gap in CSS. See [Upgrade to Astro v7](https://docs.astro.build/en/guides/upgrade-to/v7/).

> **Explanation:** The old compiler quietly repaired broken HTML, the new one from Astro 7 reports it. That is a good thing: the browser would have repaired it too, but differently than you expected. The whitespace change comes from compressHTML, which now defaults to 'jsx'. Set compressHTML: true in the config for the old behaviour, or use gap in CSS instead of spaces in your HTML.

<!-- slide 68 -->

### Task: Ship it

Builds on your second assignment. Stuck? See Client directives, SEO in the head and Deploy a static site, and [Deploy your Astro site](https://docs.astro.build/en/guides/deploy/).

1. Add one island with a client directive you can defend, like a tag filter or a dark mode toggle
2. Put title, description and Open Graph tags in your layout, with site set in astro.config.mjs
3. Turn on view transitions and check that your menu still works after a few clicks
4. Deploy to Netlify, Vercel or GitHub Pages, and put the live url in your README
5. Done when the live site works on your phone and the Audit app finds nothing on any page

> **Explanation:** Pick the island for a reason you can explain in one sentence, like a tag filter on /projects with client:visible. For the Open Graph image, a screenshot of your home page at 1200 by 630 is fine to start with. Test the live url on your phone over 4G, not only on the Wi-Fi at school.

## Glossary

- **Astro**: A web framework that renders your components to HTML on build and only sends JavaScript to the browser for the parts you mark as interactive. Built for content sites like a portfolio or a blog. (slide 5)
- **Island**: An interactive component on an otherwise static page. It loads its own JavaScript and runs on its own, the rest of the page stays plain HTML. (slide 7)
- **Hydration**: The browser loads the JavaScript of a component and attaches it to the HTML the server already rendered, so buttons start doing something. In Astro that only happens with a client directive. (slide 51)
- **Astro component**: A .astro file: a script between two --- fences that runs on build, and an HTML template below it. It renders to HTML and sends no JavaScript of its own. (slide 14)
- **Component script**: The part between the --- fences at the top of an .astro file. JavaScript or TypeScript that runs on the server or at build time, never in the browser. (slide 14)
- **Props**: The values you pass to a component as attributes, read with Astro.props. An interface Props describes which ones the component expects. (slide 16)
- **Slot**: The spot in a component where the content between its opening and closing tag ends up. A named slot takes the elements with a matching slot attribute. (slide 18)
- **Scoped style**: A style tag in an .astro file only applies to the HTML of that component. Astro adds a data-astro-cid attribute to the elements and the selectors. (slide 19)
- **Layout**: A component with the html, head and body that every page shares, and a slot where the page puts its own content. (slide 23)
- **File-based routing**: Every file in src/pages becomes a url, following its path. No routes file: about.astro becomes /about. (slide 25)
- **Dynamic route**: A page with a parameter in square brackets in its file name, like \[id\].astro, that builds a page for every value getStaticPaths returns. (slide 26)
- **getStaticPaths**: A function a dynamic route exports. It returns the list of params (and optional props) Astro builds a page for, once, at build time. (slide 26)
- **Content collection**: A set of entries of the same kind, like projects, defined in src/content.config.ts with a loader and a schema. Queried with getCollection and getEntry. (slide 32)
- **Loader**: Says where the entries of a collection come from. glob() reads a folder of Markdown files, file() one JSON or YAML file. Other loaders fetch from a CMS or an API. (slide 34)
- **Schema**: A Zod description of the frontmatter of a collection. A file that does not match stops the build, and your editor knows the type of every field. (slide 35)
- **MDX**: Markdown in which you can import and use components. In Astro through the @astrojs/mdx integration. (slide 31)
- **Client directive**: An attribute like client:load or client:visible on a framework component. It turns the component into an island and says when its JavaScript loads. (slide 51)
- **Adapter**: An integration that lets Astro render pages on demand on a host, like Netlify, Vercel, Cloudflare or a Node server. A fully static site needs none. (slide 58)
- **On-demand rendering**: A page that is rendered on the server for every request instead of once at build time. Needed for a form endpoint, cookies or data that changes per visitor. (slide 57)
- **View transition**: An animation between two pages while navigating. With CSS in the browser, or with the ClientRouter component of Astro. (slide 63)

## Links and sources

### In the deck

- [Islands architecture](https://docs.astro.build/en/concepts/islands/) (slide 7)
- [Astro extension](https://marketplace.visualstudio.com/items?itemName=astro-build.astro-vscode) (slide 11)
- [Install Astro](https://docs.astro.build/en/install-and-setup/) (slide 11): Node 22.12 or newer, npm create astro@latest
- [Project structure](https://docs.astro.build/en/basics/project-structure/) (slide 12): src/pages is the only reserved folder
- [Astro components](https://docs.astro.build/en/basics/astro-components/) (slide 17)
- [Styles and CSS](https://docs.astro.build/en/guides/styling/) (slide 19): Scoped styles, is:global, Tailwind through astro add tailwind
- [Styles and CSS: Tailwind](https://docs.astro.build/en/guides/styling/#tailwind) (slide 20)
- [Layouts](https://docs.astro.build/en/basics/layouts/) (slide 24, slide 28)
- [Routing](https://docs.astro.build/en/guides/routing/) (slide 25): Static and dynamic routes, getStaticPaths, priority, underscore files
- [@astrojs/mdx](https://docs.astro.build/en/guides/integrations-guide/mdx/) (slide 31)
- [Zod](https://zod.dev) (slide 35): The schema library behind astro/zod
- [Upgrade to Astro v6](https://docs.astro.build/en/guides/upgrade-to/v6/) (slide 39): Legacy collections, ViewTransitions and Astro.glob removed
- [Images](https://docs.astro.build/en/guides/images/) (slide 42): Image, Picture, image() in a schema, remote images
- [Content collections](https://docs.astro.build/en/guides/content-collections/) (slide 46): src/content.config.ts, glob() and file() loaders, z from astro/zod
- [Front-end frameworks](https://docs.astro.build/en/guides/framework-components/) (slide 49): Props for an island must be serializable
- [Client directives](https://docs.astro.build/en/reference/directives-reference/#client-directives) (slide 51)
- [On-demand rendering](https://docs.astro.build/en/guides/on-demand-rendering/) (slide 58): Adapters, prerender = false, output server
- [Deploy to Netlify](https://docs.astro.build/en/guides/deploy/netlify/) (slide 59)
- [Vercel](https://docs.astro.build/en/guides/deploy/vercel/) (slide 59)
- [Deploy to GitHub Pages](https://docs.astro.build/en/guides/deploy/github/) (slide 59, slide 60): site, base and the withastro/action workflow
- [Dev toolbar](https://docs.astro.build/en/guides/dev-toolbar/) (slide 65)
- [Upgrade to Astro v7](https://docs.astro.build/en/guides/upgrade-to/v7/) (slide 67): Rust compiler, stricter markup, compressHTML jsx
- [Deploy your Astro site](https://docs.astro.build/en/guides/deploy/) (slide 68)

### Sources

- [Markdown in Astro](https://docs.astro.build/en/guides/markdown-content/): Sätteri is the default processor since Astro 7
- [Scripts and event handling](https://docs.astro.build/en/guides/client-side-scripts/)
- [Template directives reference](https://docs.astro.build/en/reference/directives-reference/): client:load, idle, visible, media and only
- [View transitions](https://docs.astro.build/en/guides/view-transitions/): ClientRouter, transition:name and astro:page-load
- [Astro 7](https://astro.build/blog/astro-7/): Release post, June 2026
- [Svelte: $state](https://svelte.dev/docs/svelte/$state)
