BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. 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.

23rd August 2026·Updated on:28th August 2026··
Next.js
Prevent Draft-Mode Cache Poisoning in Next.js — Type-Safe

⚡ 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.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • Where This Bug Comes From
  • The Anti-Pattern: An Optional Boolean on a Cached Data Loader
  • Why the Usual Fixes Don't Hold Up
  • The Fix: Make Execution Mode a Type, Not a Flag
  • Step 1: Define the Context Type
  • Step 2: Restrict the Cached Function's Signature
  • Step 3: Route Through an Exhaustive Dispatcher
  • Step 4: Thread the Context Through Templates and Blocks
  • The Principles Behind the Pattern
  • FAQ
  • Wrapping Up
On this page:
  • Where This Bug Comes From
  • The Anti-Pattern: An Optional Boolean on a Cached Data Loader
  • Why the Usual Fixes Don't Hold Up
  • The Fix: Make Execution Mode a Type, Not a Flag
  • The Principles Behind the Pattern
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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:

ts
// File: src/data/blog.ts

// The anti-pattern
export async function getBlogPost(slug: string, draft: boolean = false) {
  if (draft) {
    return queryDirectFromDatabase(slug, { draft: true });
  }
  return getCachedPublishedPost(slug);
}

async function getCachedPublishedPost(slug: string) {
  "use cache: remote";
  return queryDirectFromDatabase(slug, { draft: false });
}

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.

ApproachWhat it catchesWhere it breaks down
Code reviewObvious cases where the flag is missing near the top of the treeDoesn'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
Request-scoped context (React context / AsyncLocalStorage)Works for regular server components rendered inside the requestNext.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 assertionsStops the cache write once a draft ID is detected in a published queryTurns 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

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.

Step 1: Define the Context Type

ts
// File: src/data/context.ts

export type PublishedRoutedDataContext = Readonly<{
  mode: "published";
  siteSlug: string;
  locale: string;
}>;

export type DraftRoutedDataContext = Readonly<{
  mode: "draft";
  siteSlug: string;
  locale: string;
}>;

export type RoutedDataContext =
  | PublishedRoutedDataContext
  | DraftRoutedDataContext;

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.

Step 2: Restrict the Cached Function's Signature

ts
// File: src/data/blog.ts

// Private to this module — cannot be called with a draft context
async function getCachedPublishedBlog(
  context: PublishedRoutedDataContext,
  slug: string,
): Promise<BlogView | null> {
  "use cache: remote";
  applyCachePolicy({ context, tags: [`blog:${slug}`] });
  return fetchBlogFromDatabase(context, slug, { draft: false });
}

async function queryDraftBlog(
  context: DraftRoutedDataContext,
  slug: string,
): Promise<BlogView | null> {
  return fetchBlogFromDatabase(context, slug, { draft: true });
}

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.

Step 3: Route Through an Exhaustive Dispatcher

ts
// File: src/data/blog.ts

export async function getBlogBySlug(
  context: RoutedDataContext,
  slug: string,
): Promise<BlogView | null> {
  switch (context.mode) {
    case "published":
      return getCachedPublishedBlog(context, slug);
    case "draft":
      return queryDraftBlog(context, slug);
  }
}

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
const context: DraftRoutedDataContext = { mode: "draft", siteSlug: "acme", locale: "en" };

// Error: Argument of type 'DraftRoutedDataContext' is not assignable
// to parameter of type 'PublishedRoutedDataContext'.
await getCachedPublishedBlog(context, "launch-post");
ts
// Error: Expected 2 arguments, but got 1.
await getBlogBySlug("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.

tsx
// File: src/app/[slug]/page.tsx

async function DraftModeGate({ slug }: { slug: string }) {
  const { isEnabled } = await draftMode();

  const context: RoutedDataContext = isEnabled
    ? { mode: "draft", siteSlug: "acme", locale: "en-US" }
    : { mode: "published", siteSlug: "acme", locale: "en-US" };

  return <BlogTemplate slug={slug} context={context} />;
}
tsx
// File: src/components/BlogTemplate.tsx

async function BlogTemplate({ slug, context }: { slug: string; context: RoutedDataContext }) {
  const blog = await getBlogBySlug(context, slug);
  if (!blog) return notFound();

  return (
    <article>
      <h1>{blog.title}</h1>
      <RenderBlocks blocks={blog.blocks} context={context} />
      <Suspense fallback={<RelatedPostsSkeleton />}>
        <RelatedPosts categoryId={blog.categoryId} context={context} />
      </Suspense>
    </article>
  );
}
tsx
// File: src/components/RelatedPosts.tsx

async function RelatedPosts({ categoryId, context }: { categoryId: number; context: RoutedDataContext }) {
  const posts = await getRelatedPostsByCategory(context, categoryId);
  return <PostGrid items={posts} />;
}

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.

For the draft-mode resolution step itself — the draftMode() call and the redirect/enable flow around it — see the single-route ISR and draft mode setup guide, and for wiring this up specifically against Payload CMS live preview, the Payload CMS live preview implementation guide covers the postMessage bridge this context type sits underneath.

The Principles Behind the Pattern

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