Astro Content Collections: Typed Markdown

Rodney Lab - May 16 '23 - - Dev Community

In this lesson you will learn how to use Astro Content Collections to output a list of blog posts.

Astro Content Collections

We are getting close to finishing the site we’re building in this Astro Tutorial. In the last lesson, we added blog posts. One thing missing is a list of blog posts on the home page, to make it easier for visitors to find content. Let’s build a blog roll in this lesson while taking a deeper dive into Astro Content Collections.

Auto Type Generation

We had a brief introduction to Astro Content Collections when we added blog posts to the site. At the time, we mentioned that as well as providing utility functions, Astro Collections generate types for us. We found those auto generated types in .astro/types.d.ts. Take another look at the file, and you should see it now shows all three blog posts.

Since this file is generated automatically, there is no need to commit it to your Git repo. Astro probably already added it to your .gitignore file. If you are also running Prettier or other code formatters, though, consider adding the .astro folder there. As an example, your Prettier ignore file (.prettierignore) might look like this:

# EXAMPLE ONLY

dist/**
node_modules/**
pnpm-lock.yaml

# generated types
.astro/
Enter fullscreen mode Exit fullscreen mode

Adding .astro/ instructs Prettier to ignore that folder when checking formatting. That will be super helpful if you decide to add formatting checks in your project Git hooks.

Astro Content Collection Schema Validation

Another advantage Astro Content Collections bring is front matter validation. Astro lets you define a schema for front matter fields, so it can later let you know of any front matter inconsistencies (missing title field and such like). Your schema will also include the types each field should be, again helpful for making sure your content data are consistent.

Everything we have seen so far for Astro Content Collections has been self-configured; you just needed to make sure you put your collection-related files in the right folder. Schema validation, however, does require an extra configuration file. That’s to let Astro know what policy to enforce.

Collection Configuration

Astro looks for Content Collection configuration at src/content/config.ts. If it is missing, you can still use Content Collections, albeit without schemas. Create that config file and add this content:

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

const blogCollection = defineCollection({
    schema: z.object({
        title: z.string(),
        description: z.string(),
        date: z.string(),
        draft: z.boolean()
    })
});

export const collections = {
  'blog': blogCollection,
};
Enter fullscreen mode Exit fullscreen mode

Astro Content Collection Validation: Screen capture shows an error message with text "frontmatter does not match collection schema".  Message includes a link for help with remedying the error.

Here, we define a schema for the existing blog collection. We set the types for title, description, and date to string, while draft is Boolean. Save the file, then jump to the Terminal.

Astro Content Collection Validation: Screen capture shows a browser error message with text "frontmatter does not match collection schema".  Message includes a link for help with remedying the error.

There is an error now, which was not there before! Where did it come from? By default, all fields in the schema are compulsory, and you might have noticed we did not set draft in the front matter for the tandem-cycles or unicycles posts. You have two ways to remedy this:

  • keep the draft field compulsory and add it to posts missing it
  • update the schema to make the draft field optional

Making the field optional is as easy as chaining an optional() method call on the end of boolean():

const blogCollection = defineCollection({
    schema: z.object({
        // TRUNCATED...
        draft: z.boolean().optional()
    })
 });
Enter fullscreen mode Exit fullscreen mode

Whichever method you chose, the errors should now be gone. Under the hood, Astro uses the Zod library to implement schemas. Zod handles far more sophisticated validation than we have here. As an example, you use z.string().url() if you want a front matter field to be a URL. Check Zod docs for this and more advanced features.

Astro Collection APIs

getCollection API

We saw the getCollection function briefly in the blog post template. Let’s take a closer look at some details here.

Astro lets you create multiple collections; you just need to create a folder for each collection under src/content. We named our folder blog, and that means our collection is also called blog.
You were able to access the blog collection data with getCollection passing the collection name as an argument:

import { getCollection } from "astro:content"

posts = await getCollection("blog");
Enter fullscreen mode Exit fullscreen mode

We also used the collection name to create a Props type alias for an individual post:

import type { CollectionEntry } from "astro:content";

type Props = CollectionEntry<"blog">;

const post: Props = Astro.props;
Enter fullscreen mode Exit fullscreen mode

Note, getCollection returns a promise. Astro supports top-level await within .astro front matter. That means this code works “as-is”, without having to wrap it in an async block.

The promise resolves to an array, which now looks something like this, with an element for each post:

{
  posts: [
    {
      id: 'penny-farthing.md',
      slug: 'penny-farthing',
      body: '\n' +
        '### Some interesting penny-farthing facts\n' , // TRUNCATED...
      collection: 'blog',
      data: {
        title: 'Penny-Farthing',
        description: 'All about penny-farthings',
        date: '2023-04-30T17:11:05.000+0100',
        draft: false
      },
      render: [AsyncFunction: render]
    },
    {
      id: 'tandem-cycles.md',
      // TRUNCATED...
    },
    {
      id: 'unicycles.md',
      // TRUNCATED...
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

getEntryBySlug API

If ever you need just a single blog post, rather than an array of all the Astro Collection content, there is a convenient alternative to getCollection: getEntryBySlug. getEntryBySlug takes the collection name and the slug of the entry you are requesting as arguments:

// EXAMPLE ONLY
const post = await getEntryBySlug('blog', 'penny-farthing');
Enter fullscreen mode Exit fullscreen mode

More Content Collections

To create a second (or even, third) Content Collection, you just add a new directory in src/content. Remember to add it to src/content/config.ts to get a schema.

Adding the Blog Roll

To render the blog roll, we will need a title, description and publish date for each post. We have all of these (sourced from Markdown front matter) in the getCollection result. It’s impressive that you can pull up those data, for all posts, with just a single function call. Astro Content Collections save you writing quite a few lines of code!

Typically, blog rolls start with the most recent post. We can use the JavaScript Array.sort method to get them into order. Update the front matter section of src/pages/index.astro:

---
import { getCollection } from "astro:content";
import BaseLayout from "~/layouts/BaseLayout.astro";

// TRUNCATED...

const posts = (await getCollection("blog")).sort(
  (a, b) => Date.parse(b.data.date) - Date.parse(a.data.date)
);

---
Enter fullscreen mode Exit fullscreen mode

The date field on posts is a string and Date.parse converts it to the number of milliseconds since January 1, 1970. We can then use the differences in this numerical value between posts for sorting.

Next, we want to render these data. As we did for contacts when importing JSON data for the contact page, we’ll use the Array.map method. Update the markup section of src/index.astro:

---
<BaseLayout description={description} title={title}>
  <main>
    <!-- TRUNCATED... -->

    <h2>Our recent blog posts</h2>
    <ul>
      {
        posts.map(({ data: { date, title, description }, slug }) => (
          <a href={slug}>
            <li>
              <h3>{title}</h3>
              <p class="description">{description}</p>
              <p>
                {new Date(date).toLocaleString("en", { dateStyle: "short" })}
              </p>
            </li>
          </a>
        ))
      }
    </ul>
  </main>
</BaseLayout>
Enter fullscreen mode Exit fullscreen mode

toLocaleString displays the date in US format here. Update the language code to match your locale, if needed. For example, you might change "en" to "en-GB".

The blog roll styling does not look fantastic! Add a style element at the bottom of the src/pages.index.astro file:

<!-- ...TRUNCATED -->
<style>
  ul {
    list-style-type: none;
  }

  ul a {
    text-decoration: none;
    color: inherit;
  }

  li {
    padding: var(--spacing-2) var(--spacing-4) var(--spacing-4);
    border-radius: var(--spacing-1);
  }

  a .description {
    color: var(--colour-brand);
    text-decoration: underline;
  }

  li:focus,
  li:hover {
    background-color: var(--colour-theme-tint-80);
  }

  h3 {
    margin-block: var(--spacing-2);
  }

  p {
    font-size: var(--font-size-2);
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Your home page should now look something like this:

Astro Content Collections: Screen Capture shows the site home page with a list of blog posts.  For each post, we see a title, description and date.

Wrapping Up

We only have one more lesson left in our Astro Beginners’ Tutorial series! Hopefully, this lesson helped cement your Astro Content Collections understanding. Don’t worry if it is not all 100% clear yet. In the final lesson, we add an RSS feed to the Astro site. That will grant us a further opportunity to use Content Collections. Can’t wait to tell you about adding non-HTML resources to your Astro site in the next one!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .