Your Drafts Ship With Every Build

You set draft: true, the post vanishes from the homepage, and you move on. Three weeks later someone links you to the half-written piece — the one with the placeholder heading and the TODO in the third paragraph. They found it in Google.
Nobody leaked it. Your build published it.
A draft flag is a filter you apply at a call site. It is not a property of the content. If one call site forgets to apply it, the page gets written to dist/, and everything downstream of the filesystem treats that file as a live page — because it is one.
The filter lives in one place; the build has many
Here is the shape of the bug, in Astro:
// src/lib/posts.ts — the list view filters correctly
export async function getAllPosts() {
const posts = await getCollection('posts', ({ data }) => !data.draft)
return posts.sort((a, b) => b.data.publishedAt - a.data.publishedAt)
}
// src/pages/posts/[slug].astro — and this one does not
export async function getStaticPaths() {
const posts = await getCollection('posts') // every entry, drafts included
return posts.map((post) => ({
params: { slug: post.data.slug },
props: { post },
}))
}
The index is clean. The card never renders. And dist/posts/half-written-thing/index.html exists anyway, fully rendered, served with a 200, readable by anything that finds the URL.
This is not an Astro problem. Every static framework separates “which pages exist” from “which pages are listed”:
- Next.js —
generateStaticParams()decides what gets built. Your listing page’s data fetch is a different function entirely. - Eleventy —
eleventyExcludeFromCollections: trueremoves a file from collections. It still writes the output file unless you also setpermalink: false. - Hugo —
draft: trueis honored byhugoand ignored byhugo -D, and the-Dgets added to a CI config for preview builds and never taken back out.
Three different mechanisms, one shared assumption: that the author will remember to filter in every place that matters.
Count the surfaces
Once the file exists in the output directory it stops being your problem and starts being the platform’s. Every one of these consumes the build output rather than your content query:
| Surface | Reads from | Sees your draft? |
|---|---|---|
| The page itself | Emitted routes | Yes — it is a real 200 |
sitemap.xml | Emitted routes | Yes, when generated from the route list |
| RSS / Atom feed | Your query | Only if the query filters |
| Search index | dist/ on disk | Yes — it crawls files |
| OG image generation | Per-page hooks | Yes |
| Prev / next links | Your query | Usually no |
The row that catches people is the search index. Pagefind runs after the build, over the output directory:
astro build && pagefind --site dist
It has no idea what a content collection is. It sees HTML files and indexes them. So the draft doesn’t merely sit at a guessable URL — it is a first-class result in your own site search, ranked beside your real posts, with its unfinished excerpt as the preview text.
The sitemap row cuts both ways, and it’s worth understanding properly. @astrojs/sitemap enumerates the routes the build actually emitted. That makes it derived: it inherits your filtering for free when you filtered at the route level, and it faithfully advertises your drafts when you didn’t. A sitemap cannot save you. It only reports what you built.
Obscurity is not access control
The usual defense is that nobody knows the URL. That defense has a short half-life.
Slugs come from titles, and titles are predictable — /posts/2027-pricing-update/ is not a hard guess. But you rarely need guessing, because URLs escape on their own:
- You paste the link into Slack and the unfurl bot fetches it.
- The page links back to your real posts, and something crawls it in reverse from a referrer log.
- The slug lands in your sitemap during the one deploy where the filter was wrong, gets crawled, and stays in the index long after you fix it.
That last one is the expensive failure, because the instinct for cleaning it up is backwards. Removing a URL from your sitemap does not remove it from a search index. Engines drop a page when they recrawl it and find a 404, a 410, or a noindex — and a robots.txt disallow prevents that recrawl. Blocking the crawler after the fact is the one move that guarantees the stale entry sticks around.
noindex was never privacy either. It asked politely. The bytes were always readable by anyone holding the URL.
Filter once, at the source
The fix is architectural, not a checklist item. Make it impossible for a route to see a draft by giving the codebase exactly one door to content:
// src/lib/posts.ts — the only place getCollection is ever called
import { getCollection, type CollectionEntry } from 'astro:content'
export type PostEntry = CollectionEntry<'posts'>
export async function getAllPosts(): Promise<PostEntry[]> {
const posts = await getCollection('posts', ({ data }: PostEntry) => !data.draft)
return posts.sort(
(a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime(),
)
}
Every route, the feed, and the category pages call getAllPosts() and nothing else. The route generator inherits the filter, so the file is never written, so the sitemap never lists it and the search indexer never finds it on disk. One correct decision, applied everywhere by construction.
Then enforce the chokepoint with a lint rule instead of a code-review habit:
{
"rules": {
"no-restricted-imports": ["error", {
"paths": [{
"name": "astro:content",
"importNames": ["getCollection"],
"message": "Use getAllPosts from src/lib/posts.ts instead."
}]
}]
}
}
Add an override for src/lib/posts.ts itself. Now a new page that reaches for the raw collection fails CI rather than quietly publishing your notes.
Scheduled posts are the same bug wearing a hat
Future-dated posts have identical mechanics and get overlooked more often, because the sort order hides the symptom.
A post dated three weeks out sorts to the top of a descending list, so it looks more published, not less. If your filter checks draft and never checks the date, that post goes live the moment you deploy anything at all. Your carefully timed announcement ships alongside an unrelated typo fix.
Filter both in the same function:
const now = Date.now()
const posts = await getCollection('posts', ({ data }: PostEntry) => {
return !data.draft && data.publishedAt.getTime() <= now
})
Note the consequence: the build becomes a point-in-time snapshot. The post does not appear when its date arrives — it appears at the first build after its date arrives. If you want scheduled publishing, you need a scheduled build. A nightly cron on your host is the entire implementation, and it is a better answer than any “publish at” feature bolted onto a static pipeline.
Make the build prove it
Filtering correctly is good. Verifying it is better, because the check survives refactors that the filter does not.
// scripts/check-drafts.mjs
import { readdir, readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
const files = await readdir('content/posts')
const drafts = []
for (const file of files) {
const raw = await readFile(`content/posts/${file}`, 'utf8')
if (/^draft:\s*true\s*$/m.test(raw)) {
drafts.push(file.replace(/\.mdx?$/, ''))
}
}
const leaked = drafts.filter((slug) => existsSync(`dist/posts/${slug}/index.html`))
if (leaked.length > 0) {
console.error(`Draft posts were emitted to dist/:\n ${leaked.join('\n ')}`)
process.exit(1)
}
console.log(`${drafts.length} drafts, 0 emitted.`)
Wire it into the build so it cannot be skipped:
{
"scripts": {
"build": "astro build && node scripts/check-drafts.mjs && pagefind --site dist"
}
}
Ordering matters. Run the check before the indexer so a leak fails the build instead of getting indexed and then failing the build.
If you want to preview drafts, build them deliberately and put them somewhere else — a separate deploy, behind HTTP basic auth, from a build that sets an explicit flag. A preview host with real auth is a preview. A production deploy with an unlinked URL is a publication you have decided not to link to.
The takeaway
Static hosting has no authorization layer. There is no session, no role, no per-request check — there is a filesystem and a CDN, and every file in it is a public URL the instant it is served. draft: true is a hint to your build script and nothing more.
So the only question that matters is: is the file in dist/? Not whether it’s linked, not whether it’s in the sitemap, not whether it carries a noindex. If the build wrote it, you published it.
Go count the post directories in your own output folder. Then count the posts you meant to publish. Those two numbers should match.

