Astro 5's content layer, in practice

Notes from the week I migrated this very site to the new content loader API.

· 4 min read
Astro 5's content layer, in practice

Notes from digging into Astro 5’s content layer. This site is on Astro, and I’m always about a major version behind, so it was overdue.

Quick context: Astro is a static site generator built around the “islands” idea — ship mostly HTML, opt into interactivity per-component. I rewrote this site from Hugo to Astro earlier this year and it’s been good. The thing that’s new in 5.x is the content layer, which is a rewrite of how Astro loads content collections.

What the content layer actually changes

In Astro 4, content collections were basically “a folder of markdown files that the build reads in.” The schema was validated, the types were generated, it worked, but it was tightly coupled to “markdown on disk.”

In 5, content is loaded through a loader interface. A loader is a function that returns entries. The built-in glob() loader reads files from disk the old way. But you can also write a loader that fetches from a CMS API, reads from a database, hits a REST endpoint, pulls from Notion, whatever. The schema validation and the type generation still work exactly the same way, regardless of where the content came from. From the component’s point of view, it’s all the same getCollection() call.

The config for this site now looks like:

// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const posts = defineCollection({
  loader: glob({ pattern: '*/index.md', base: './src/content/posts' }),
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    author: z.string().default('Abdul Ghiasy'),
    // ...
  }),
});

export const collections = { posts };

Which looks almost identical to the 4.x version — the big shift is the loader: field, which in 4.x was implicit.

Why this matters even if you don’t need it today

The content layer’s big trick is that it caches loader output between builds. If your loader is fetching from a remote API, it only re-fetches entries that have actually changed. A site pulling from a CMS that previously took 2 minutes to build because it was hitting the API every time now builds in 5 seconds after the first run. For a static file loader, it doesn’t matter. For anything remote, it’s transformative.

The secondary benefit: the loader contract is small enough that writing a custom one is a short afternoon, not a framework project. If you’ve ever wanted “Astro + my weird bespoke content source” without maintaining a plugin, this is that.

For this site specifically

Nothing changed. I’m loading markdown from disk, the glob() loader does exactly what the implicit 4.x behavior did, the schema is the same, the build time is fine. The migration was basically “add loader: glob(...) to the collection definition” and done.

But the thing I’m noting for future-me is that the option to swap loaders is now there. If I ever want to move posts to a headless CMS, or pull some entries from an external source, I don’t have to restructure anything — I just write a loader. That’s a good framework evolution. You don’t feel the benefit until you need it, and when you need it, it’s a 30-minute change instead of a rewrite.

The migration itself

For anyone doing the 4→5 upgrade on an existing content-heavy site: the one thing to watch for is that the old implicit globbing behavior is gone. If your content/config.ts didn’t specify a loader, it worked in 4.x because there was a default. In 5.x you have to be explicit. The error message is clear and the fix is one line. Otherwise the migration was painless for me — probably an hour including reading the docs twice.

#Astro #Static sites