Creating Drafts in Astro 5

Part of the Astro series
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:
- 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(), }),});
- 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>
- And finally, use it in a draft post!
---title: Creating Drafts in Astrodescription: describedate: "2025-03-25T00:10:00-08:00"keywords: ["keyword"]draft: trueslug: "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.