Every post on this site is an MDX file with a draft flag in its frontmatter. The intent is simple: I want to read an unfinished post in the browser, laid out properly, before anyone else can see it.

My first version had a single function for loading posts, and it filtered drafts out. That gave me a clean production site and no way to preview anything. Removing the filter fixed previewing and put my unfinished writing into the sitemap. Neither is acceptable, and I kept flipping between them because one function was answering two different questions.

Two questions, not one

The two questions are:

What exists for a reader on the live site? Published posts only — and that answer has to hold for listing pages, the sitemap, the RSS feed, and direct URL access.

What exists for me while I'm writing? Everything, including drafts, but only on my machine.

Once I wrote those down as separate questions, the fix was two functions:

/** Excludes drafts everywhere. Use for anything crawlers consume. */
export function getPublishedPosts(): Post[] {
  return getAllPosts().filter((post) => !post.draft);
}
 
/** Includes drafts while writing locally; production sees published only. */
export function getVisiblePosts(): Post[] {
  return process.env.NODE_ENV === "development"
    ? getAllPosts()
    : getPublishedPosts();
}

getVisiblePosts is what the pages call. getPublishedPosts is what the sitemap and the feed call, without exception.

The important property is that getVisiblePosts collapses into getPublishedPosts outside of development. Production has one behaviour, not a behaviour that depends on a flag I might forget to set.

Why the sitemap gets its own rule

It would have been shorter to let the sitemap call getVisiblePosts too, since in production the two return the same thing. I deliberately didn’t.

A sitemap is a promise to a crawler that these URLs exist and are worth fetching. If a draft ever reaches it, Google requests a page that returns a 404, and I’ve spent trust for nothing. Keeping a function whose name says published wired directly into every crawler-facing surface means that mistake requires someone to actively change the wrong line, rather than just forgetting a condition.

The comments above each function exist for the same reason. They’re not describing what the code does — that’s obvious — they’re recording which one you’re allowed to use where.

Drafts have to 404, not just hide

Hiding a draft from listings isn’t enough. If someone guesses or shares the URL, the page itself has to refuse to render.

That falls out of the same change, because the lookup by slug also goes through getVisiblePosts:

export function getPostBySlug(slug: string, type?: PostType): Post | undefined {
  return getVisiblePosts().find((post) => {
    if (post.slug !== slug) return false;
    if (type && post.type !== type) return false;
    return true;
  });
}

In production a draft slug returns undefined and the route calls notFound(). There’s also a second layer for free: generateStaticParams builds its list from the same source, so a draft never gets a statically generated page in the first place.

Making the state visible while writing

The last piece was a rendering detail. Once drafts look exactly like published posts locally, I lose track of which is which, and eventually I’ll wonder why a post I "published" isn’t live.

So the article header shows a small badge when post.draft is true. It only ever appears on my machine, since production can’t load a draft at all, and it means the answer to "is this live?" is on the page instead of in the frontmatter.

None of this is clever. The lesson I took from it is narrower than the code: when one function keeps giving me the wrong answer, it’s often because I’m asking it two questions and only naming one of them.