This approach to drafts for Gatsby supports:

  • No extra frontmatter
  • Folder-based drafts management
  • Public post previews
  • Keeping post previews unlisted in sitemaps

Gatsby does not come with a built-in way to support post drafts. As I moved this blog from Jekyll to Gatsby recently, I did a bit of research and ended up implementing my own version of a draft system because I was not satisfied with the existing plugins or available solutions. Most importantly, I wanted to keep unfinished writings in a separate drafts folder and simply drag completed posts to the main posts folder without additional frontmatter.

Caveat: I have only used this method on small projects. Due to using multiple queries it may not be performant at scale. This post will be updated if I further optimize for performance.

All that said, I now feel it is my duty to share my setup in case it helps anyone else who is interested in a similar publishing workflow!

Gatsby Drafts Project Setup

The most important aspects of this setup are the drafts and posts folders, which is where your active posts all live.

However, as this is a blog that I began in 2007 I have some extremely defunct posts which I have archived using an additional archive folder which is managed via Git but ignored in site builds.

Project Folders

These are the project folders I use for managing Markdown files:

- src
    - archive
    - drafts
    - posts

Archive: Posts I have removed from the public-facing site but wish to keep for posterity. This is purely a storage folder.

Drafts: Posts I am actively working on and wish to see in development, but should not be included in standard queries on the live site.

Posts: Posts that are widely available! These are live and show up throughout the public version of the website.

Required and Optional Plugins

This tutorial requires no additional plugins.

If you are using the gatsby-plugin-sitemap plugin, you’ll find optional instructions to ensure your drafts do not appear in the sitemap on Step 5.

1. Enable Relevant Markdown Folders

First, you’ll need to be able to query posts based on the Markdown source name set in gatsby-config.js. To do this, we’ll set up Gatsby to import both the drafts and posts folders.

Ensure you have both folders set as gatsby-source-filesystem sources in gatsby-config.js:

module.exports = {
  // ...
  plugins: [
    // ...
    {
      resolve: "gatsby-source-filesystem",
      options: {
        name: "posts",
        path: "./src/posts/",
      },
      __key: "posts",
    },
    {
      resolve: "gatsby-source-filesystem",
      options: {
        name: "drafts",
        path: "./src/drafts/",
      },
      __key: "drafts",
    },
    //...
  ],
};

2. Add a SourceType Query Field

Next, we need to ensure we can use GraphQL to query our Markdown posts based on their source type, e.g. either drafts or posts.

This means adding a custom field based on the key we set in gatsby-config.js during the build phase using gatsby-node.js. The following code is based on Michael Rose’s snippet shared here.

exports.onCreateNode = async ({
    node,
    getNode,
    actions,
}) => {
    const { createNodeField } = actions;

    if (['MarkdownRemark', 'Mdx'].includes(node.internal.type)) {
        // Set sourcename for easier querying.
        const fileNode = getNode(node.parent);
        createNodeField({
            node,
            name: 'sourceName',
            value: fileNode.sourceInstanceName,
        });
    }
};

3. Create a Collection Route

To build out your Collection Route, which is your Single Post template, you can follow the official Gatsby tutorial for setting up Markdown pages here.

Additional Notes

This Collection Route seem to automatically work for Markdown files based on the key you provide in the filename. Since I am using permalink in the frontmatter instead of slug, mine looks like this:

{markdownRemark.frontmatter__permalink}.js

...and it works like a charm.

All of your drafts and posts files should work with this template.

4. Create an Archive Page

In documenting this process I have come to realize I am likely not leveraging GraphQL edges or nodes queries correctly or most efficiently to build out pagination. So, keep in mind there are likely some optimizations left to be desired. That being said, you can either use a pagination plugin to build your pagination pages or cook your own solution like I have. I will not be reviewing how to make pagination buttons here, but you’ll be able to access /blog/, /blog/2/, and so on.

Regardless of how you generate archive pages and navigation between them, you will want to read through my setup to see how I am creating two queries: One for all posts, another for public posts only.

Because my blog is so small, I do this in two separate queries because it felt easier. However, it may slow down your site if you have a huge site with lots of posts. Alternatively, you could use just one query and filter out drafts at the template level instead. Feel free to try both ways and see which works better for you! Or any other way that makes sense to you!

Note: These examples are live from this site at the time of writing and I will try to keep them current as I update the site. They are imperfect but working as desired in my use case. Iterate and improve as you wish!

Step 1. The Template

I created a theme/layouts/blog.js file to be called later in gatsby-node.js using this query:

export const pageQuery = graphql`
    query ArchivePageQuery($skip: Int!, $limit: Int!) {
        archive: allMarkdownRemark(
            sort: { frontmatter: { date: DESC } }
            limit: $limit
            skip: $skip
            filter: { fields: { sourceName: { eq: "posts" } } }
        ) {
            posts: edges {
                post: node {
                    ...PostPreview
                }
            }
        }
        drafts: allMarkdownRemark(
            sort: { frontmatter: { date: DESC } }
            limit: $limit
            skip: $skip
        ) {
            posts: edges {
                post: node {
                    ...PostPreview
                }
            }
        }
    }
`;

The PostPreview GraphQL fragment should contain whatever fields you need for your template. Here is mine:

fragment PostPreview on MarkdownRemark {
    id
    excerpt(pruneLength: 250)
    frontmatter {
        title
        date(formatString: "D MMMM, YYYY")
        modified: last_modified_at(formatString: "D MMMM, YYYY")
        excerpt
        permalink
    }
    fields {
        type: sourceName
    }
}

The template you need for your site will vary from mine.

You will not be able to directly copy and past because I have a few unique elements. This includes a custom <Layout> wrapper that contains my website’s header, footer, and other global elements. I also created a generatePagination function and <BlogPagination> component that handles my pagination. Finally, there is a simple <PostPreview> component which accepts and formats individual blog post information so I can consistently show post previews no matter where they appear on the site.

The important part to notice here is the const posts declaration where we use process.env.NODE_ENV to swap out whether we are looking at all posts including drafts, or only posts excluding drafts.

const ArchivePage = (props) => {
    const { data, pageContext } = props;
    const { currentPage, numArchivePages } = pageContext;
    const pagination = generatePagination(currentPage, numArchivePages);

    const posts =
        'development' === process.env.NODE_ENV
            ? data.drafts.posts
            : data.archive.posts;

    return (
        <Layout>
            <h1 className="text-center text-7xl font-black mt-10 font-serif tracking-tighter mb-20">
                Articles
            </h1>
            <div className="max-w-3xl mx-auto">
                {posts.map((node, index) => {
                    const { post } = node;
                    return (
                        <PostPreview
                            key={`post-preview-${index}`}
                            index={index}
                            post={post}
                        />
                    );
                })}
            </div>
            <BlogPagination pagination={pagination} />
        </Layout>
    );
};

The important bit:

Make sure your GraphQL query includes an option for public only vs all posts (I labeled mine as archive and drafts) which you can then swap to use in your template based on whether you are accessing the site in development mode versus a live build.

Step 2. Generating the Archive

Once your template and query are set up properly, it’s time to build out your actual archive pages that can be linked to via pagination on startup.

In gatsby-node.js you can use createPages to create a similar query as in the previous step to generate the right number of pages in development vs public build mode.

Note: The public-posts-only query always uses filter: { fields: { sourceName: { eq: "posts" } } }, where the drafts-inclusive query ignores filters and collects all Markdown files regardless of origin.

Then, we use the same environmental dependency to establish which set of posts we want to use with this line:

const posts =
    'development' === process.env.NODE_ENV
        ? content.data.drafts.edges
        : content.data.posts.edges;

From there, we calculate the number of archive pages we need based on how many posts we want to see per page.

const postsPerPage = 5;
const numArchivePages = Math.ceil(posts.length / postsPerPage);

Finally, we use the createPage function to build out the archive pages using the template component we created in the last step.

Array.from({ length: numArchivePages }).forEach((_, i) => {
    createPage({
        path: i === 0 ? `/blog/` : `/blog/${i + 1}/`,
        component: path.resolve('./src/theme/layouts/blog.js'),
        context: {
            limit: postsPerPage,
            skip: i * postsPerPage,
            numArchivePages,
            remainder: posts.length - i * postsPerPage,
            currentPage: i + 1,
        },
    });
});

In my case, the final createPages statement on gatsby-node.js looks like this:

exports.createPages = async ({ actions, graphql, reporter }) => {
    const { createPage } = actions;

    const content = await graphql(`
        {
            posts: allMarkdownRemark(
                sort: { frontmatter: { date: DESC } }
                filter: { fields: { sourceName: { eq: "posts" } } }
            ) {
                edges {
                    node {
                        ...PostData
                    }
                }
            }
            drafts: allMarkdownRemark(sort: { frontmatter: { date: DESC } }) {
                edges {
                    node {
                        ...PostData
                    }
                }
            }
        }

        fragment PostData on MarkdownRemark {
            id
            fields {
                sourceName
            }
        }
    `);
    if (content.errors) {
        reporter.panicOnBuild(`Error while running GraphQL query.`);
        return;
    }

    const posts =
        'development' === process.env.NODE_ENV
            ? content.data.drafts.edges
            : content.data.posts.edges;

    const postsPerPage = 5;
    const numArchivePages = Math.ceil(posts.length / postsPerPage);

    Array.from({ length: numArchivePages }).forEach((_, i) => {
        createPage({
            path: i === 0 ? `/blog/` : `/blog/${i + 1}/`,
            component: path.resolve('./src/theme/layouts/blog.js'),
            context: {
                limit: postsPerPage,
                skip: i * postsPerPage,
                numArchivePages,
                remainder: posts.length - i * postsPerPage,
                currentPage: i + 1,
            },
        });
    });
};

5. Optional: Adjust Your Sitemap

If you are using the Gatsby Sitemap plugin, you will need to customize the query to ignore drafts. As a side benefit we’ll make sure that last updated dates show correctly while we’re at it.

Frontmatter field requirements:

  1. permalink (or other frontmatter for storing the path)
  2. last_modified_at

As an example, here is this post’s frontmatter at this stage in its draft:

---
title: Configuring Gatsby to Allow Draft Posts
excerpt: Gatsby does not natively support a draft status for writing and publishing posts. Here is my folder-based solution for managing drafts on a Gatsby blog.
date: "2023-11-07"
last_modified_at: "2023-11-07"
permalink: /gatsby-drafts-system/
---

We customize the sitemap plugin by creating a custom query to add modified dates to public posts and to retrieve draft permalinks, which we will filter out from the final page list for the sitemap to generate from.

Here is my gatsby-config.js configuration for the gatsby-sitemap-plugin:

// At the top of the page:
const siteUrl = process.env.URL || `https://www.dorko.dev`;

// ...inside the plugins: [] array:
{
    resolve: 'gatsby-plugin-sitemap',
    options: {
        query: `{
        allSitePage {
          nodes {
            path
          }
        }
        allMarkdownRemark(
          filter: {fields: {sourceName: {eq: "posts"}}}
        ) {
          nodes {
            frontmatter {
              permalink
              modified:last_modified_at
            }
          }
        }
        drafts:allMarkdownRemark(
          filter: {fields: {sourceName: {eq: "drafts"}}}
        ) {
          nodes {
            frontmatter {
              permalink
            }
          }
        }
      }`,
        resolveSiteUrl: () => siteUrl,
        resolvePages: ({
            allSitePage: { nodes: allPages },
            allMarkdownRemark: { nodes: allMarkdown },
            drafts: { nodes: draftMarkdown },
        }) => {
            // Collect an array of draft paths.
            const draftPaths = draftMarkdown.map(
                (node) => node.frontmatter.permalink
            );
            // Create an array of last modified dates.
            const markdownMap = allMarkdown.reduce((acc, node) => {
                const { frontmatter } = node;
                const { permalink, modified } = frontmatter;
                acc[permalink] = modified;
                return acc;
            }, {});
            // Filter out drafts from the entire Page list.
            const filteredPages = allPages.filter((page) => {
                return !draftPaths.includes(page.path);
            });
            // Return public pages and add modified timestamps when applicable.
            return filteredPages.map((page) => {
                return { ...page, modified: markdownMap[page.path] };
            });
        },
        serialize: ({ path, modified }) => {
            return {
                url: path,
                lastmod: modified,
            };
        },
    },
},

Note: If you use path or slug instead of permalink then you will need to update your GraphQL query to reflect that.

If you are running into issues, you may need to try npm run build to look for build errors. The npm run develop process seems to quietly fail for this plugin.

6. Other Queries

Let’s say you want to show the most recent several posts on the homepage, which is what I’ve done on this site. You will want to make sure to query based on sourceName like we did when we built out the archives.

Here is the query I use on the dorko.dev homepage:

const data = useStaticQuery(graphql`query LatestPosts {
    archive:allMarkdownRemark(
        limit: 3
        sort: {frontmatter: {date: DESC}}
        filter: {fields: {sourceName: {eq: "posts"}}}
      ) {
      posts:edges {
        post:node {
          ...PostPreview
        }
      }
    }
    drafts:allMarkdownRemark(
      limit: 3
      sort: {frontmatter: {date: DESC}}
    ) {
    posts:edges {
      post:node {
        ...PostPreview
      }
    }
  }
}`)

I then use the same type of environmental dependency to determine which query to use later in the template:

const posts =
    'development' === process.env.NODE_ENV
        ? data.drafts.edges
        : data.posts.edges;

This creates a posts constant for me to map through later on, where I feed individual post data to my <PostPreview> component for consistent representation.

{posts.map((node, index) => {
  const { post } = node;
  return (
      <PostPreview
          key={`post-${index}`}
          index={index}
          post={post}
      />
  );
})}

Basically, wherever you want to query posts to display them, you will need to ensure you’re being intentional about where you show drafts or not by using the correct filter and determining which environment you are in before mapping through the results.

7. Bonus: Add a DRAFT Badge

As a small quality of life bonus, we can add a drafts signifier so it’s easy to spot which posts we’re working on in development mode.

First check your PostPreview fragment from when we generated the Archive page to ensure you are querying the sourceName for use in the template. I use type as a label so it’s easier to read later on.

export const query = graphql`
    fragment PostPreview on MarkdownRemark {
        id
        excerpt(pruneLength: 250)
        frontmatter {
            title
            date(formatString: "D MMMM, YYYY")
            modified: last_modified_at(formatString: "D MMMM, YYYY")
            excerpt
            permalink
        }
        fields {
            type: sourceName
        }
    }
`;

Then, simply check for the drafts value against that field within your component. Here is a span styled with TailwindCSS.

// At the top of my PostPreview component...
const { frontmatter: info, fields } = post;

return(
   <>
   {/* ... PostPreview Component ... */}
   {'drafts' === fields.type && (
      <span className="inline-block bg-yellow-400 dark:bg-yellow-600 dark:text-white rounded px-2 py-1 mb-3 font-bold ml-5">
          DRAFT
      </span>
   )}
   {/* ... PostPreview Component ... */}
   </>
)

Happy Drafting!

Other quality of life things I would like to figure out how to do in VS Code, which I will probably write a new post for if I get around to it:

  • If a markdown file is updated in drafts, update the filename to begin with today’s date and update both date: and last_modified_at: frontmatter fields
  • Create a new draft post script/task

Otherwise... I’ll come back through to update this post if I find a better way to do all this that fits my preferred workflow. Until then, happy drafting!