Last updated on
Adding drafts was the easy part
My blog used to publish the instant I hit save. Every keystroke that survived a save was live — no drafts, no history, no "let me sit on this overnight." It's built on Payload and Next.js, and I finally wired up a proper editorial workflow: drafts, a publish button, and full version history with diffs and one-click restore.
The feature itself is almost embarrassingly small. Payload has a first-party versions system, so enabling all of it is one config block per collection:
versions: {
maxPerDoc: 50,
drafts: true,
},That's it. Documents now carry a _status of draft or published, the admin grows a Versions tab with diffs and restore, and a migration adds the tables. Half a day of work, most of it not the config.
The interesting part, the part worth writing down, is everything that one flag quietly touched. Adding drafts taught me more about my access control and my deploy pipeline than about drafts.
Lesson 1: your access rule doesn't run where you think it does
With drafts on, the obvious next step is to stop serving unpublished posts to the public. Payload access control makes that a one-liner: anonymous readers get a filter, logged-in admins see everything.
read: ({ req: { user } }) =>
user ? true : { _status: { equals: "published" } },I flipped that on, felt good about it, and it did nothing for my actual pages.
Here's the catch. My frontend reads posts through Payload's Local API (payload.find(...) running in the same process), and the Local API defaults to overrideAccess: true — it bypasses the access rule entirely. That rule governs the public REST/GraphQL API, but the pages my visitors actually load were sailing straight past it. Drafts would have shipped to the homepage, the blog index, and the RSS feed.
The fix is to filter explicitly in every query the frontend makes, not to rely on the access rule:
const PUBLISHED = { _status: { equals: "published" } } as const;
// ...where: PUBLISHED (and merged with the slug filter on detail pages)Access control and query filtering are two different layers here, and drafts is exactly the feature that makes you learn the difference the hard way.
Lesson 2: the migration that hides all your content
Enabling versions generates a migration that adds a _status column. Innocuous — except the column's default value is 'draft'. Apply that migration as-is and every existing post silently flips to draft, then vanishes behind the published-only filter I'd just added. The whole blog would 404 the moment the schema changed.
So the single most important line in the migration isn't schema at all — it's a backfill:
UPDATE "posts" SET "_status" = 'published' WHERE "_status" IS DISTINCT FROM 'published';Every row that existed before drafts was, by definition, already live. This is the plain expand/contract idea: make the schema change safe for the data that's already there before the new code depends on it. It also meant I could apply the migration to production before merging the code — the old, still-running site never reads _status, so adding the column and backfilling it is invisible to it.
Lesson 3: the preview build that can't build
This is the one that cost me the most time, and it had nothing to do with drafts.
My Vercel preview deployments prerender pages at build time — and they connect to the production database to do it (they did back then, anyway — the preview database that was still production is the later fix). So the moment a PR adds a column the code depends on, the preview build runs SELECT ... _status ... against a prod database that doesn't have _status yet, and the build dies:
error: column posts._status does not exist
Failed to collect page data for /blog/[slug]A schema-change PR can't go green until production has the migration — but you'd normally apply the migration as part of shipping the PR. Chicken, meet egg. (The same bind, compounded by a second schema PR landing in parallel — this very drafts feature was that second PR — is when two migrations collide.)
The way out, given the expand/contract safety from Lesson 2, is to migrate production first, then let the preview (and later the merge) build against a database that already has the column. Which led directly to the dumbest, most memorable bug of the whole exercise:
I ran my "migrate production" script from the wrong directory. The new migration lived on my feature branch; I ran the script from a checkout sitting on main, where the file didn't exist. Payload dutifully read the migrations folder it could see, found nothing new, and printed Done. — a perfectly successful run that applied nothing. Twenty minutes of "but I migrated it" later, the fix was to run the script from the branch that actually contained the migration. Migration tooling reads the files on disk, not the ones in your head.
Lesson 4: the reviewer caught what my tests couldn't
I had unit tests asserting the published filter was on all four of my query helpers. I had a runtime check confirming a drafted post disappeared from the list. Everything green. I opened the PR.
The automated reviewer on the repo immediately flagged two leaks I'd missed, both downstream of Lesson 1. My series navigation loads related posts through a depth: 1 relationship, which populates via the Local API, overrideAccess and all: a drafted post in a series still leaked its title, inflated the "Part 3 of 5" count, and rendered a link on its published siblings' pages. My four filtered queries were correct; the relationship wasn't. My pages, meanwhile, got a publish state but no cache invalidation, so unpublishing the homepage could leave a stale copy cached.
Both were real. Both were the kind of thing that doesn't show up until someone (or something) thinks about the paths you didn't test. The lesson from Lesson 1 generalizes further than I'd applied it: overrideAccess bypasses every populated relationship too, not just top-level queries.
What actually shipped
Under all the lessons, the improvements are simple and I'm happy with them. Drafts let me write privately and publish deliberately, so saves no longer go straight to the world. Every save now becomes a version, with author and timestamp, a diff view, and restore of either the whole document or a single field, capped at 50 per document so the table doesn't grow forever. The public site, RSS, and API serve only published content, including through series relationships. And the cache refreshes on publish and unpublish, not on every keystroke-y draft save.
What I'd tell myself before starting
Adding a feature is rarely just adding the feature. This one touched the security boundary (whose queries does access control actually govern?), the data (what happens to rows that predate the change?), and the deploy pipeline (what does a build read, and when?). The Payload flag was the smallest part.
Next up in the same vein: required approvers before publish, and finally getting production migrations to run automatically on deploy so I stop migrating from the wrong directory. But that, mercifully, is a different post.
Comments
No comments yet. Be the first to comment.