How to switch to Astro Content Collections
It’s day 14 of Blogvent, where I blog every day in December!
I wrote in a post yesterday about how I updated my blog (which is open source, and you can copy and use the template!) to use the latest and greatest from Astro and TinaCMS, and how I finally am using Content Collections now.
So… what does that even mean?
Great question. It’s all about how markdown files are ultimately converted to HTML on my website in Astro.
Before, I was using a function called Astro.glob()
to load in markdown files to generate static pages in the template.
This worked well! I was able to call the function like so:
const posts = await Astro.glob("../posts/*.md");
And from there, I was able to pull the frontmatter (also known as the metadata) of each markdown file in my Astro components to build my blog layouts.
The frontmatter of all of my blog posts (including this one!) followed the same convention:
---
layout: "../layouts/BlogPost.astro"
title: "Some title"
slug: the-blog-url
description: "This is a description"
added: "Dec 13 2024"
updated: "Dec 14 2024"
tags: [technical, meta]
---
...blah blah...
Again, this worked well! Well enough that I fully ignored that Astro came out with Content Collections back in version 2.0 (and 5.0 just came out this month… it’s been a while).
That being said, when you fully ignore good things that come your way, you lose out on the quality-of-life improvements that open source maintainers make for you. Whoda thunk it?
The cons of glob
The main thing I didn’t like about the Astro.glob()
setup is that all of my blog posts had to be in a folder inside of my src/
folder. As in, sitting right in the actual source code. I had tried some workarounds, but that was just how it had to be, all posts in src/posts/
. Not the end of the world, but it didn’t feel right.
Astro.glob()
also is being deprecated, in favor of Vite’s import.meta.glob()
. Again, not the end of the world, and I think that function still has a place in certain instances, but it just wasn’t as optimized for content. A lot more parsing had to be done of the imported markdown frontmatter in general, too.
The pros of Content Collections
Content Collections in Astro, I’ve learned, are really what you should use for content, because they’re optimized for it.
You define a schema for your content (which introduces type safety), your folders of content can live anywhere (even outside of your repository!!), and Astro has some really nice APIs for getting single entries of content, and whole collections, as well as rendering that content.
So, all this being said, actually setting up Content Collections was a smaller lift than I thought it would be.
The first thing you do is define your collection schema:
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const posts = defineCollection({
loader: glob({ pattern: "*.md", base: "./posts" }),
schema: z.object({
title: z.string(),
slug: z.string(),
description: z.string(),
added: z.union([z.string(), z.date()]),
updated: z.union([z.string(), z.date()]).optional(),
tags: z.array(z.string()),
}),
});
export const collections = { posts };
And this assumes all of your existing content fits this schema “shape”! Astro uses Zod for schemas and typing, if you’re wondering what that z
is. You might notice a couple things here:
- My dates for my blog posts are either strings or dates, depending on the markdown file. This was a weird one to figure out (and it’s my own fault): The date object is the result of TinaCMS generating actual dates when I use its calendar component, and the string is the result of me generating the date in Obsidian before I publish (more on how I publish from Obsidian here). Anyway, the
union
function made that work great. - Where’s the layout? Oh baby, I’ll tell ya soon.
After that file is added, it’s a matter of replacing the glob
calls everywhere else! Remember the glob
code sample from before, now transformed:
// before
const posts = await Astro.glob("../posts/*.md");
// after
import { getCollection } from "astro:content";
const posts = await getCollection("posts");
And anything that references frontmatter
, like post.frontmatter.title
, would be swapped with data
, like post.data.title
, and that’s about it for finalizing the conversion.
You can see the full diff here of these changes.
But wait, what about that layout thing?
Okay, the one thing that I don’t really love about Content Collections is that you can’t define a layout in your markdown anymore.
I wasn’t actually using this, but I liked the idea of being able to say “this specific blog is special” and point at custom layouts from there.
That being said, there are workarounds that are actually pretty easy to work with, so I can’t complain too much.
My dynamic route post/[slug].astro
(where URLs are defined for individual posts), which normally had the glob
call plus the Astro <Content />
component, now has the layout defined there instead:
---
import { getCollection, render } from "astro:content";
import BlogPost from "../../layouts/BlogPost.astro";
export async function getStaticPaths() {
let posts = await getCollection("posts");
return posts.map((post) => {
return {
params: { slug: post.data.slug },
props: { post },
};
});
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BlogPost content={post.data}>
<Content />
</BlogPost>
(this is all in the diff I linked above)
If I wanted to do a fancy layout for a certain post, I’d just have to pick a different kind of property to flag it in, and do some logic here. I don’t love it, still, but it works.
It’s done!
I hope this is helpful for you to understand why I made the switches, what it took, and how you might be able to apply them to your own Astro projects. I admit I dragged my feet for way too long on these updates because I didn’t understand the benefits (or how quick the changes could be), and I’m happy with the results!
See you tomorrow!