---
title: "Prevent Draft-Mode Cache Poisoning in Next.js — Type-Safe"
slug: "prevent-draft-mode-cache-poisoning-nextjs"
published: "2026-08-23"
updated: "2026-08-28"
validated: "2026-08-28"
categories:
  - "Next.js"
tags:
  - "draft mode cache poisoning"
  - "Next.js cache poisoning"
  - "RoutedDataContext"
  - "discriminated union TypeScript"
  - "prevent cache leaks Next.js"
  - "use cache unstable_cache"
  - "Payload CMS live preview"
  - "server components caching"
  - "AsyncLocalStorage limitation"
  - "type-safe caching patterns"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js"
  - "typescript"
  - "react"
  - "payload cms"
  - "vs code"
status: "stable"
llm-purpose: "Draft mode cache poisoning in Next.js can leak unpublished content. Use a RoutedDataContext discriminated union and TypeScript checks to stop cache leaks…"
llm-prereqs:
  - "Access to Next.js"
  - "Access to TypeScript"
  - "Access to React"
  - "Access to Payload CMS"
  - "Access to VS Code"
llm-outputs:
  - "Completed outcome: Draft mode cache poisoning in Next.js can leak unpublished content. Use a RoutedDataContext discriminated union and TypeScript checks to stop cache leaks…"
---

**Summary Triples**
- (Prevent Draft-Mode Cache Poisoning in Next.js — Type-Safe, focuses-on, Draft mode cache poisoning in Next.js can leak unpublished content. Use a RoutedDataContext discriminated union and TypeScript checks to stop cache leaks…)
- (Prevent Draft-Mode Cache Poisoning in Next.js — Type-Safe, category, general)

### {GOAL}
Draft mode cache poisoning in Next.js can leak unpublished content. Use a RoutedDataContext discriminated union and TypeScript checks to stop cache leaks…

### {PREREQS}
- Access to Next.js
- Access to TypeScript
- Access to React
- Access to Payload CMS
- Access to VS Code

### {STEPS}
1. Define RoutedDataContext discriminated union
2. Restrict cached functions to published context
3. Implement an exhaustive dispatcher
4. Thread context through templates and blocks
5. Run type checks and CI safeguards

<!-- llm:goal="Draft mode cache poisoning in Next.js can leak unpublished content. Use a RoutedDataContext discriminated union and TypeScript checks to stop cache leaks…" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to VS Code" -->
<!-- llm:output="Completed outcome: Draft mode cache poisoning in Next.js can leak unpublished content. Use a RoutedDataContext discriminated union and TypeScript checks to stop cache leaks…" -->

# Prevent Draft-Mode Cache Poisoning in Next.js — Type-Safe
> Draft mode cache poisoning in Next.js can leak unpublished content. Use a RoutedDataContext discriminated union and TypeScript checks to stop cache leaks…
Matija Žiberna · 2026-08-23

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.

<table>
<tr><th>Approach</th><th>What it catches</th><th>Where it breaks down</th></tr>
<tr><td>Code review</td><td>Obvious cases where the flag is missing near the top of the tree</td><td>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</td></tr>
<tr><td>Request-scoped context (React context / AsyncLocalStorage)</td><td>Works for regular server components rendered inside the request</td><td>Next.js intentionally isolates <code>"use cache"</code> functions from the incoming request so they can be evaluated independently of it; request-scoped state doesn't cross that boundary</td></tr>
<tr><td>Runtime assertions</td><td>Stops the cache write once a draft ID is detected in a published query</td><td>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</td></tr>
</table>

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 <a href="https://www.buildwithmatija.com/blog/nextjs-draft-mode-isr-single-route-previews">single-route ISR and draft mode setup guide</a>, and for wiring this up specifically against Payload CMS live preview, the <a href="https://www.buildwithmatija.com/blog/live-preview-payload-cms-nextjs-implementation-guide">Payload CMS live preview implementation guide</a> 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 <a href="https://www.buildwithmatija.com/blog/nextjs-16-2-caching-unstable-cache-vs-use-cache">Next.js 16.2 caching comparison</a> 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

## LLM Response Snippet
```json
{
  "goal": "Draft mode cache poisoning in Next.js can leak unpublished content. Use a RoutedDataContext discriminated union and TypeScript checks to stop cache leaks…",
  "responses": [
    {
      "question": "What does the article \"Prevent Draft-Mode Cache Poisoning in Next.js — Type-Safe\" cover?",
      "answer": "Draft mode cache poisoning in Next.js can leak unpublished content. Use a RoutedDataContext discriminated union and TypeScript checks to stop cache leaks…"
    }
  ]
}
```