Proper websites, done properly

Part 4: Content collections

10 minute read time / 1191 words

Content collections are Astro's way of organising related content like blog posts or news articles. In this part we'll start setting up our first content collection: blog posts.

Creating a collection

Content collections were added in Astro 2.0.0 and are a great way to organise all content of a certain type such as news, blog posts, products, guides, or any other type of content that is easily grouped together. We can start a new collection by creating a new folder structure. Go ahead and make a content folder in src. In there, create another folder called posts.

Each folder we create in the src/content folder is another collection, so if we decide later on to add a latest news section, we can just create a folder at src/content/news/ and start adding news articles to make a new collection.

Our content pages in a collection can be in either Markdown or MDX for general content pages, or if you prefer a data-driven approach they can also be in either YAML or JSON format. We're going to opt for MDX as it will let us easily use JSX syntax inside Markdown files to give us more flexibility down the line.

To use MDX, we need to install the official integration. In your terminal, install the integration:

npx astro add mdx

Step through any prompts to install the plugin. Asto tries to automatically update your config file for you in this process provided you answer 'yes' when it asks! To check this, just open it and take a look in .astro.config.mjs and you should see this:

import { defineConfig } from 'astro/config';

import mdx from "@astrojs/mdx";

// https://astro.build/config
export default defineConfig({
  integrations: [mdx()]
});

As per the Astro documentation, we need to define our collection before we can do anything with it. Create a new file at src/content/config.ts and add the following:

import { defineCollection } from 'astro:content';

const postsCollection = defineCollection({ });

export const collections = {
    'posts': postsCollection,
};

Heads up!

You might need to update the TypeScript config tsconfig.json to make things work right.

Schemas

The great thing about frontmatter in static site generators is that it's so flexible. You can add whatever you want to it. The problem with it is that you can add whatever you want to it.

Astro tries to help out here by allowing us to define and enforce a schema on a collection so that you have to fill in the correct and required metadata for it to work.

We can set this up in our newly created config.ts file by updating our postsCollection variable and importing the utilties we need.

import { z, defineCollection } from 'astro:content';

const postsCollection = defineCollection({
    type: 'content',
    schema: z.object({
        title: z.string(),
    }),
});

export const collections = {
    'posts': postsCollection,
};

Astro 'type'

type was introduced as of Astro 2.5, and allows you to define whether a page is "content" (Markdown, MDX) or "data" (JSON, YAML).

We'll need more in our schema later. Things like published dates, whether or not the page is a draft, a hero image and author information. This is enough for us to get our basic collection functionality working.

Creating our first post

Now we're ready to start looking at adding our first couple of blog posts to this collection. Astro's documentation states that you can call any page in a collection whatever you want, and they don't have to follow any sort of convention. It makes sense for everybody's sanity that you decide on this now, and stick to that convention for the collection you make moving forward.

Let's name ours using a date-based convention so that they make sense when we come back to them in future. Create a new blog post file in the posts folder called 2024-03-15-my-first-blog-post.mdx and put the following code in it:

---
title: "This is my first blog post!"
---
This is my first blog post, and it is magnificent!

Great! But how do we get to this page? Pages in content collections do not get routes (page URLS) generated for them automatically like files in the src/pages folder. What we need is a generic post 'page' that will be used for each post in our posts collection.

Create our post 'page'

Create a new file at src/pages/posts/[...slug].astro and let's get our new blog post output on our site.

---
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
    const postEntries = await getCollection('posts');

    return postEntries.map(entry => ({
        params: { slug: entry.slug }, props: { entry },
    }));
}

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

Now we should be able to visit the URL defined by the collections folder structure and the post's filename. Try to access http://localhost:4321/posts/2024-03-15-my-first-blog-post and you should see a page with the title and content we added into the MDX file.

Posts landing page

To help people find our post pages we're going to need a posts landing page. Astro provides built-in ways to access whole collections or individual items in a collection. We're going to use getCollection() to help us loop through our posts collection and output an entry on each one so we can visit /posts/ on our site to see all our content.

Create our landing page at src/pages/posts/index.astro and add the following code:

---
import { getCollection } from 'astro:content';
const postEntries = await getCollection('posts');
---
<ul>
    {postEntries.map(postEntry => (
        <li>
            <a href={`/posts/${postEntry.slug}`}>
                {postEntry.data.title}
            </a>
        </li>
    ))}
</ul>

Now if you visit /posts/ on your site, you should see a list with a single entry that links to your post page. Magnifique!

To finish off making our collection available, we just need to get it into our global navigation component, and update both our post listing and post content pages to wrap our <Layout /> component around their contents.

First, update the navigation in src/components/Nav.astro:

---
const navPages = [
    {
        url: '/about',
        title: 'About Me'
    },
    {
        url: '/stuff',
        title: 'Stuff and Things'
    },
    {
        url: '/posts',
        title: 'Posts'
    }
];
---
<nav>
    {navPages.map(page =>
        <a href={page.url}>{page.title}</a>
    )}
</nav>

Now for our posts landing page:

---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

const postEntries = await getCollection('posts');
---
<Layout title="Blog posts">
    <ul>
        {postEntries.map(postEntry => (
            <li>
                <a href={`/posts/${postEntry.slug}`}>
                    {postEntry.data.title}
                </a>
            </li>
        ))}
    </ul>
</Layout>

And finally our post content page:

---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

export async function getStaticPaths() {
    const postEntries = await getCollection('posts');

    return postEntries.map(entry => ({
        params: { slug: entry.slug }, props: { entry },
    }));
}

const { entry } = Astro.props;
const { Content } = await entry.render();
---
<Layout title="Blog posts">
    <h1>{entry.data.title}</h1>
    <Content />
</Layout>

Eccellente! It's all coming together nicely.