Creating Drafts in Astro 5

Last week or so, I started writing a blog post as I sometimes do, this one pertaining to my Automation Workflow for Media Reviews. Unfortunately, I wanted to preview it as I went along, so I copied it in progress to my git main branch of the local copy of the website.

You can see where this is going.

Yes, I updated something else on the website and published it, INCLUDING the partial draft of the blog post I was working on. This wasn’t a super huge deal, except that I use EchoFeed to automatically post to Bluesky and Mastodon whenever I post something new on the site.

Sigh.

The good news is that this finally pushed me to add drafts functionality to my site, so that I could have drafts render when running locally in development mode, but not actually get written when doing a site build. It’s a good, basic feature to have.

Initially I started with the method shared by Alex Curtis in his post How to Create a Draft Post in Astro, but his filter didn’t actually work for me. I think this is because his example was for a different version of Astro, possibly. I wound up using the Astro Docs example for Filtering Content Collection Queries.

Basically, there are three steps to adding draft posts to Astro 5:

  1. Add an optional draft data property to your blog post collection in your content.config.ts, as below,
const postCollection = defineCollection({
  loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/posts" }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      description: z.string(),
      link: z.string().optional(),
      date: z
        .string()
        .transform((str) => new Date(str))
        .optional()
        .nullable(),
      keywords: z.string().array(),
      cover: image().optional(),
      coverAlt: z.string().optional(),
      series: z.string().optional(),
      draft: z.boolean().optional(),
    }),
});
  1. Filter based on this in any page that uses this content collection, the way the Astro Docs show,
---
import Base from "../layouts/Base.astro";
import Post from "../components/Post.astro";
import Pager from "../components/Pager.astro";
import { getCollection } from "astro:content";
import site from "../data/site.json";

export async function getStaticPaths({ paginate }) {
  let posts = await getCollection("posts", ({ data }) => {
    return import.meta.env.PROD ? data.draft !== true : true;
  });

  posts = posts.sort(
    (a, b) => new Date(b.data.date).valueOf() - new Date(a.data.date).valueOf(),
  );

  return paginate(posts, {
    pageSize: site.posts.paginationSize,
  });
}
const { page } = Astro.props;

const title = site.title;
const description = `Posts Page ${page.currentPage}`;
---

<Base title={title} description={description}>
  <section aria-label="Post list" data-pagefind-ignore>
    {
      page.data.map((post, index) => {
        return <Post post={post} />;
      })
    }
    <Pager page={page} />
  </section>
</Base>
  1. And finally, use it in a draft post!
---
title: Creating Drafts in Astro
description: describe
date: "2025-03-25T00:10:00-08:00"
keywords: ["keyword"]
draft: true
slug: "creating-drafts-in-astro"
---
I’ve always wanted to be a writer, and I’ve always wanted to create drafts in Astro that won’t get published until I want them to.

[Now I can!](https://jacurtis.com/notes/astro-draft-posts/)

One more thing though – none of this keeps the post page itself from being rendered during a build. It just keeps anything from linking to it or showing it in a list of posts. This means that it will show up in your RSS feed unless you edit your RSS template to also filter it out.

import rss from "@astrojs/rss";
import sanitizeHtml from "sanitize-html";
import { rfc2822 } from "../components/utilities/DateFormat";
import { globalImageUrls } from "../components/utilities/StringFormat";
import site from "../data/site.json";

export function GET(context) {
  const postImportResult = import.meta.glob("../content/posts/**/*.md", {
    eager: true,
  });
  const posts = Object.values(postImportResult)
    .filter((post) => post.frontmatter.draft !== true)
    .sort(
      (a, b) =>
        new Date(b.frontmatter.date).valueOf() -
        new Date(a.frontmatter.date).valueOf(),
    );

  return rss({
    title: site.title,
    description: site.description,
    site: context.site,
    xmlns: {
      atom: "http://www.w3.org/2005/Atom/",
      dc: "http://purl.org/dc/elements/1.1/",
      content: "http://purl.org/rss/1.0/modules/content/",
    },
    items: posts.map((post) => ({
      title: post.frontmatter.title,
      link: `${site.url}${post.frontmatter.slug}`,
      pubDate: rfc2822(post.frontmatter.date),
      description: post.frontmatter.description,
      customData: `<summary>${post.frontmatter.description}</summary>`,
      content: globalImageUrls(
        site.url,
        sanitizeHtml(post.compiledContent(), {
          allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
        }),
      ),
    })),
  });
}

That’s it! Hit me up on Bluesky or Mastodon if you have any questions.