Prevent Draft-Mode Cache Poisoning in Next.js — Type-Safe
Prevent Draft-Mode Cache Poisoning in Next.js — Type-Safe
Prevent draft-mode cache leaks in Next.js using RoutedDataContext (discriminated union) for safe, type‑enforced caching.
·Updated on:··
⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Draft content leaking into a public cache is one of the most common failure modes in CMS-backed Next.js applications, and it almost always traces back to the same root cause: an optional draft?: boolean = false parameter that gets dropped somewhere in a deep component tree. This guide shows the pattern I now use on every project with a live preview mode: replace the boolean flag with a discriminated union type called RoutedDataContext, and restrict every cached data function so it can only accept the published variant of that type. Once the type system enforces this, a developer forgetting to thread draft state through a nested widget becomes a compile-time error in their editor rather than a data leak in production.
Where This Bug Comes From
I ran into this on a multi-brand Payload CMS build where product pages pulled in a dozen nested blocks: hero, rich text with resolved internal links, a related-products widget, a recommendations carousel. Draft mode worked fine at the top level. Three layers down, inside a small "related posts" component nobody had touched in months, the draft flag never got passed. The published cache started serving unreleased product names to public visitors an hour before launch, and there wasn't a single error in the logs to point at.
That combination is what makes this bug dangerous: it produces no crash, no failed request, and no obvious signal in monitoring. It just quietly writes the wrong data into a cache that every visitor shares.
The Anti-Pattern: An Optional Boolean on a Cached Data Loader
Most CMS-backed data loaders in Next.js, Remix, or Astro start out looking like this:
Calling getBlogPost("my-slug") without a second argument reads cleanly, and that's exactly the problem. The default value means the function works correctly when a caller forgets the flag, right up until it silently reads and caches published content for an author who is supposed to be looking at a draft, or caches a draft-only entity because a nested component queried it without knowing it was in draft mode at all.
A single page render in a real application triggers a cascade of these loaders:
text
Product Page (draft mode = true)
└─ Hero
└─ Rich Text Body (resolves internal product links)
└─ Product Card Resolver <- draft flag not threaded through
└─ Related Products Widget <- draft flag not threaded through
A component four layers deep that calls getPostsByCategory(categoryId) without the flag defaults to false. If that category happens to resolve a draft-only entity, the query result gets written into the shared cache under "use cache: remote", and every visitor to that page now sees it.
Why the Usual Fixes Don't Hold Up
Three fixes come up every time this bug gets discussed, and each one has a specific gap.
None of these move the check earlier than runtime, and runtime is already too late for a cache write that happens on the first request from any visitor.
The Fix: Make Execution Mode a Type, Not a Flag
The goal is to make it impossible to call a cached function without proving, at the type level, that the current request is in published mode. A discriminated union does this cleanly.
mode is the discriminant. TypeScript uses this literal field to narrow the union whenever it sees a check or a switch on it, which is what makes the rest of the pattern work.
getCachedPublishedBlog only accepts PublishedRoutedDataContext. Passing a DraftRoutedDataContext here is a type error, not a runtime check, so the mistake shows up in the editor before the code ever ships.
context has no default value here, so every caller has to supply one explicitly. With strict mode on, TypeScript also flags this switch as non-exhaustive if a third mode gets added to the union later and isn't handled, which keeps the dispatcher honest as the state machine grows.
With this in place, both failure modes turn into compiler errors:
ts
constcontext: DraftRoutedDataContext = { mode: "draft", siteSlug: "acme", locale: "en" };
// Error: Argument of type 'DraftRoutedDataContext' is not assignable// to parameter of type 'PublishedRoutedDataContext'.awaitgetCachedPublishedBlog(context, "launch-post");
ts
// Error: Expected 2 arguments, but got 1.awaitgetBlogBySlug("launch-post");
Both show up as a red underline in the editor, on the line that introduced the mistake, before a build runs and long before a visitor loads the page.
Step 4: Thread the Context Through Templates and Blocks
The context needs to travel as a first-class prop from the point where draft mode is resolved down through every nested component that fetches data.
Once context is a required prop on every server component that touches a data loader, there's no place left in the tree where a developer can quietly skip it. The compiler requires the value at every call site, all the way down.
Three ideas from type-driven design carry over directly to caching code:
Encode state, not booleans. A boolean tells the compiler a value is true or false. A discriminated union ties that value to the exact shape of data and behavior that goes with it, so the compiler can reason about what each branch is allowed to do.
Require proof for dangerous operations. Writing to a shared cache is a dangerous operation because the blast radius is every visitor who hits that cache key. Requiring a PublishedRoutedDataContext argument is a type-level proof token: only code that has already resolved published mode can produce one.
Avoid optional booleans on public interfaces.draft?: boolean = false, skipAuth?: boolean = false, force?: boolean = false all share the same shape of risk — the default silently activates the safe-looking path even when the caller meant the dangerous one. Making the parameter required removes the silent path entirely.
If you're deciding between "use cache" and unstable_cache for the cached branch itself, the Next.js 16.2 caching comparison walks through the trade-offs; the pattern above applies to either directive the same way, since the type restriction lives one layer above the cache primitive.
FAQ
Does this pattern require Next.js Cache Components / "use cache"?
No. The type restriction works the same way with unstable_cache, a Redis wrapper, or any other caching layer. What matters is that the cached function's parameter type only accepts the published context, regardless of which caching primitive sits inside it.
Why not just use AsyncLocalStorage to carry the draft flag implicitly?"use cache" functions in Next.js are evaluated independently of the incoming request so they can be cached and reused across requests. Request-scoped storage doesn't survive that boundary, so a global or context-based flag disappears exactly where you need it most — inside the cached function itself.
Is this overkill for a site without deeply nested components?
If every data fetch happens in one file with a handful of direct calls, a code review can catch a missing flag reliably. The pattern earns its cost once you have blocks, widgets, or CMS-driven layouts where a data loader gets called from more than two or three levels of component composition.
Can I extend RoutedDataContext with more than two modes?
Yes — a preview mode with a signed token, or a staging mode with a different data source, both fit as additional members of the union. The exhaustive switch in the dispatcher will flag any new mode that isn't handled yet, as long as strict mode is on.
What if a third-party library expects a plain boolean?
Keep the discriminated union at your application's data-loader boundary and convert to a boolean only at the last possible call site, right where the third-party function needs it. That keeps the type safety intact everywhere in your own code and confines the boolean conversion to one line.
Wrapping Up
Cache poisoning from a dropped draft flag is a bug that produces no error and no failed request, which is exactly what makes it hard to catch with tests or monitoring alone. Replacing the optional boolean with a RoutedDataContext discriminated union, restricting cached functions to the published variant, and routing everything through an exhaustive dispatcher moves the check from a 2am incident to a red squiggly line in VS Code. It's a small amount of type ceremony that removes an entire category of production incident.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks,
Matija
// File: src/data/blog.ts
// The anti-pattern
export
async
function
getBlogPost
slug: string, draft: boolean = false
if
return
queryDirectFromDatabase
draft
true
return
getCachedPublishedPost
async
function
getCachedPublishedPost
slug: string
"use cache: remote"
return
queryDirectFromDatabase
draft
false
Approach
What it catches
Where it breaks down
Code review
Obvious cases where the flag is missing near the top of the tree
Doesn't scale past a handful of files; a boolean threaded through 20+ nested props is easy to lose track of as the team and component tree grow
Works for regular server components rendered inside the request
Next.js intentionally isolates "use cache" functions from the incoming request so they can be evaluated independently of it; request-scoped state doesn't cross that boundary
Runtime assertions
Stops the cache write once a draft ID is detected in a published query
Turns the bug into a 500 error in production the moment an editor previews new content, which trades a silent data leak for a visible outage
Readonly
mode
"published"
siteSlug
string
locale
string
export
type
DraftRoutedDataContext
Readonly
mode
"draft"
siteSlug
string
locale
string
export
type
RoutedDataContext
PublishedRoutedDataContext
DraftRoutedDataContext
// File: src/data/blog.ts
// Private to this module — cannot be called with a draft context