Back to Blog

Next.js Draft Mode: CMS Previews, ISR Invalidation, and Preview API Patterns

Implement Next.js Draft Mode for CMS content previews. Covers enabling draft mode via API route, bypassing ISR cache, Contentful and Sanity preview integration, and secure preview URL generation.

Viprasol Tech Team
March 18, 2027
11 min read

Next.js ISR is excellent for performance โ€” pages are statically generated and served from CDN. The problem is content editors. When a writer updates a blog post in Contentful, they want to see the change immediately, not wait for the next revalidation cycle. Draft Mode solves this: editors get a special URL that bypasses the static cache and renders the page fresh with unpublished content.

This guide covers Draft Mode in the App Router with Contentful, Sanity, and custom CMS implementations.

How Draft Mode Works

  1. Editor clicks "Preview" in the CMS
  2. CMS sends a request to your /api/draft/enable route with a secret token
  3. Next.js sets a __prerender_bypass cookie in the response
  4. Editor is redirected to the content URL
  5. For subsequent requests, Next.js detects the cookie and renders dynamically (bypassing cache)
  6. draftMode().isEnabled returns true in Server Components
  7. You fetch draft/unpublished content instead of published content

Basic Setup

// app/api/draft/enable/route.ts
import { NextRequest, NextResponse } from "next/server";
import { draftMode } from "next/headers";

export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;
  const secret = searchParams.get("secret");
  const slug = searchParams.get("slug");
  const type = searchParams.get("type") ?? "post";
  const redirect = searchParams.get("redirect");

  // 1. Verify the secret
  if (secret !== process.env.PREVIEW_SECRET) {
    return NextResponse.json(
      { message: "Invalid token" },
      { status: 401 }
    );
  }

  // 2. Enable Draft Mode (sets __prerender_bypass cookie)
  (await draftMode()).enable();

  // 3. Redirect to the content page
  const destination = redirect
    ? decodeURIComponent(redirect)
    : slug
    ? `/${type === "post" ? "blog" : type}/${slug}`
    : "/";

  return NextResponse.redirect(new URL(destination, req.url));
}

// app/api/draft/disable/route.ts
import { draftMode } from "next/headers";
import { NextResponse } from "next/server";

export async function GET() {
  (await draftMode()).disable();
  return NextResponse.redirect(new URL("/", process.env.NEXT_PUBLIC_APP_URL!));
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Draft Mode in Server Components

// app/blog/[slug]/page.tsx
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { getPost, getDraftPost } from "@/lib/cms";
import { DraftBanner } from "@/components/draft-banner";

interface BlogPostPageProps {
  params: { slug: string };
}

export default async function BlogPostPage({ params }: BlogPostPageProps) {
  const { isEnabled: isDraft } = await draftMode();

  // Fetch draft or published content based on mode
  const post = isDraft
    ? await getDraftPost(params.slug)   // Includes unpublished drafts
    : await getPost(params.slug);        // Published only

  if (!post) notFound();

  return (
    <article>
      {/* Draft Mode banner โ€” visible only in preview */}
      {isDraft && <DraftBanner slug={params.slug} />}

      <h1>{post.title}</h1>
      {post.isDraft && (
        <p className="text-yellow-600 text-sm font-medium">
          โš ๏ธ This post is not yet published
        </p>
      )}
      <div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
    </article>
  );
}

// Static generation โ€” only for non-draft mode
export async function generateStaticParams() {
  const posts = await getPublishedPostSlugs();
  return posts.map((slug) => ({ slug }));
}

// Disable static rendering in draft mode
export const dynamic = "auto"; // Default; Next.js detects draft cookie
// components/draft-banner.tsx
import Link from "next/link";
import { Eye, X } from "lucide-react";

export function DraftBanner({ slug }: { slug: string }) {
  return (
    <div className="sticky top-0 z-50 bg-yellow-400 text-yellow-900 px-4 py-2.5 flex items-center justify-between text-sm font-medium">
      <div className="flex items-center gap-2">
        <Eye className="w-4 h-4" />
        <span>Preview Mode โ€” viewing draft content</span>
      </div>
      <Link
        href={`/api/draft/disable`}
        className="flex items-center gap-1 hover:underline"
      >
        <X className="w-3.5 h-3.5" />
        Exit Preview
      </Link>
    </div>
  );
}

Contentful Integration

// lib/cms/contentful.ts
import { createClient } from "contentful";

// Two clients: one for published, one for preview (drafts)
const publishedClient = createClient({
  space: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
});

const previewClient = createClient({
  space: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN!,
  host: "preview.contentful.com", // Preview API endpoint
});

export interface ContentfulPost {
  slug: string;
  title: string;
  excerpt: string;
  contentHtml: string;
  publishedAt: string;
  author: { name: string; avatar: string };
  isDraft: boolean;
}

export async function getPost(slug: string): Promise<ContentfulPost | null> {
  const entries = await publishedClient.getEntries({
    content_type: "blogPost",
    "fields.slug": slug,
    limit: 1,
  });

  if (!entries.items.length) return null;
  return transformEntry(entries.items[0], false);
}

export async function getDraftPost(
  slug: string
): Promise<ContentfulPost | null> {
  // Preview API returns both published and draft content
  const entries = await previewClient.getEntries({
    content_type: "blogPost",
    "fields.slug": slug,
    limit: 1,
  });

  if (!entries.items.length) return null;
  return transformEntry(entries.items[0], true);
}

function transformEntry(entry: any, isDraft: boolean): ContentfulPost {
  const fields = entry.fields;
  return {
    slug: fields.slug,
    title: fields.title,
    excerpt: fields.excerpt ?? "",
    contentHtml: renderRichText(fields.body), // Use @contentful/rich-text-html-renderer
    publishedAt: entry.sys.createdAt,
    author: {
      name: fields.author?.fields?.name ?? "Editorial Team",
      avatar: fields.author?.fields?.avatar?.fields?.file?.url ?? "",
    },
    isDraft,
  };
}

export async function getPublishedPostSlugs(): Promise<string[]> {
  const entries = await publishedClient.getEntries({
    content_type: "blogPost",
    select: ["fields.slug"],
    limit: 1000,
  });
  return entries.items.map((e: any) => e.fields.slug);
}

Contentful Preview URL Configuration

In your Contentful space settings โ†’ Content Preview:

Preview URL: https://yoursite.com/api/draft/enable?secret={env.PREVIEW_SECRET}&slug={entry.fields.slug}&type=post&redirect={entry.fields.slug}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Sanity Integration

// lib/cms/sanity.ts
import { createClient } from "@sanity/client";

const config = {
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET ?? "production",
  apiVersion: "2027-03-18",
  useCdn: false, // Always fresh โ€” we control caching via Next.js ISR
};

// Published client (CDN for production)
export const sanityClient = createClient({
  ...config,
  useCdn: true,
});

// Preview client (authenticated, bypasses CDN)
export const sanityPreviewClient = createClient({
  ...config,
  token: process.env.SANITY_API_READ_TOKEN,
  perspective: "previewDrafts", // Returns draft versions of documents
});

export async function getSanityPost(slug: string, preview = false) {
  const client = preview ? sanityPreviewClient : sanityClient;

  const query = `*[_type == "post" && slug.current == $slug][0]{
    _id,
    title,
    "slug": slug.current,
    excerpt,
    body,
    publishedAt,
    _updatedAt,
    "isDraft": !defined(publishedAt),
    author->{name, "avatar": image.asset->url}
  }`;

  return client.fetch(query, { slug });
}
// app/blog/[slug]/page.tsx (Sanity version)
import { draftMode } from "next/headers";
import { getSanityPost } from "@/lib/cms/sanity";
import { PortableText } from "@portabletext/react";

export default async function BlogPostPage({
  params,
}: {
  params: { slug: string };
}) {
  const { isEnabled } = await draftMode();
  const post = await getSanityPost(params.slug, isEnabled);

  if (!post) notFound();

  return (
    <article>
      {isEnabled && <DraftBanner slug={params.slug} />}
      <h1>{post.title}</h1>
      <PortableText value={post.body} />
    </article>
  );
}

ISR Cache Invalidation via Webhook

When content is published in the CMS, invalidate the ISR cache immediately:

// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath, revalidateTag } from "next/cache";

export async function POST(req: NextRequest) {
  // Verify webhook signature
  const signature = req.headers.get("x-webhook-signature");
  const secret = process.env.WEBHOOK_SECRET!;

  const body = await req.text();

  // Verify HMAC signature (Contentful/Sanity webhook format)
  const expectedSig = await computeHmac(secret, body);
  if (signature !== expectedSig) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const payload = JSON.parse(body);

  try {
    // Contentful webhook
    if (payload.sys?.contentType?.sys?.id === "blogPost") {
      const slug = payload.fields?.slug?.["en-US"];
      if (slug) {
        await revalidatePath(`/blog/${slug}`);
        revalidateTag(`post-${slug}`);
      }
      // Also revalidate blog index
      revalidatePath("/blog");
    }

    // Sanity webhook
    if (payload._type === "post") {
      const slug = payload.slug?.current;
      if (slug) {
        revalidatePath(`/blog/${slug}`);
        revalidateTag(`sanity-post-${slug}`);
      }
      revalidatePath("/blog");
    }

    return NextResponse.json({ revalidated: true });
  } catch (err) {
    return NextResponse.json(
      { error: "Revalidation failed" },
      { status: 500 }
    );
  }
}

async function computeHmac(secret: string, body: string): Promise<string> {
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
  return Buffer.from(sig).toString("hex");
}

Custom CMS: Generating Secure Preview URLs

For custom CMS systems, generate time-limited preview URLs server-side:

// lib/preview/generate-url.ts
import { createHmac } from "crypto";

interface PreviewUrlParams {
  slug: string;
  type: string;
  expiresInSeconds?: number;
}

export function generatePreviewUrl({
  slug,
  type,
  expiresInSeconds = 3600, // 1 hour
}: PreviewUrlParams): string {
  const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
  const payload = `${type}:${slug}:${expiresAt}`;
  const signature = createHmac("sha256", process.env.PREVIEW_SECRET!)
    .update(payload)
    .digest("hex")
    .slice(0, 16); // Short signature for URL readability

  const params = new URLSearchParams({
    type,
    slug,
    expires: String(expiresAt),
    sig: signature,
  });

  return `${process.env.NEXT_PUBLIC_APP_URL}/api/draft/enable?${params}`;
}

// Validate in the enable route
export function validatePreviewToken(params: URLSearchParams): boolean {
  const type = params.get("type");
  const slug = params.get("slug");
  const expires = params.get("expires");
  const sig = params.get("sig");

  if (!type || !slug || !expires || !sig) return false;

  // Check expiry
  if (parseInt(expires) < Math.floor(Date.now() / 1000)) return false;

  // Verify signature
  const payload = `${type}:${slug}:${expires}`;
  const expectedSig = createHmac("sha256", process.env.PREVIEW_SECRET!)
    .update(payload)
    .digest("hex")
    .slice(0, 16);

  return sig === expectedSig;
}
// Updated enable route with custom token validation
import { validatePreviewToken } from "@/lib/preview/generate-url";

export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;

  // Support both simple secret and signed token
  const secret = searchParams.get("secret");
  const hasSig = searchParams.has("sig");

  if (hasSig) {
    if (!validatePreviewToken(searchParams)) {
      return NextResponse.json({ message: "Invalid or expired token" }, { status: 401 });
    }
  } else if (secret !== process.env.PREVIEW_SECRET) {
    return NextResponse.json({ message: "Invalid token" }, { status: 401 });
  }

  const slug = searchParams.get("slug");
  const type = searchParams.get("type") ?? "post";

  (await draftMode()).enable();

  const destination = slug
    ? `/${type === "post" ? "blog" : type}/${slug}`
    : "/";

  return NextResponse.redirect(new URL(destination, req.url));
}

Draft Mode with fetch() Cache Tags

// lib/cms/fetch-with-tags.ts
import { draftMode } from "next/headers";

export async function fetchWithDraftSupport<T>(
  publishedFetcher: () => Promise<T>,
  draftFetcher: () => Promise<T>
): Promise<T> {
  const { isEnabled } = await draftMode();
  return isEnabled ? draftFetcher() : publishedFetcher();
}

// Usage in page.tsx
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await fetchWithDraftSupport(
    () => getPost(params.slug),      // cached, tagged
    () => getDraftPost(params.slug)  // never cached in draft mode
  );
  // ...
}

Environment Variables

# .env.local

# Next.js Draft Mode
PREVIEW_SECRET=a-strong-random-32-char-secret

# Contentful
CONTENTFUL_SPACE_ID=your_space_id
CONTENTFUL_ACCESS_TOKEN=your_delivery_token         # Published content
CONTENTFUL_PREVIEW_ACCESS_TOKEN=your_preview_token  # Draft content

# Sanity
SANITY_PROJECT_ID=your_project_id
SANITY_DATASET=production
SANITY_API_READ_TOKEN=your_read_token               # For previewDrafts perspective

# Webhook validation
WEBHOOK_SECRET=another-strong-secret

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic Draft Mode setup1 dev0.5 day$200โ€“400
CMS integration (Contentful or Sanity)1 dev1โ€“2 days$400โ€“800
Full system (preview + webhook revalidation + custom tokens)1 dev3โ€“5 days$1,000โ€“2,000
Multi-CMS / headless commerce preview1โ€“2 devs1โ€“2 weeks$2,500โ€“5,000

See Also


Working With Viprasol

Content editors shouldn't need a developer to preview their changes. Draft Mode wires up your CMS preview workflow so editors can see exactly what will publish before it goes live โ€” no cache confusion, no waiting for revalidation. Our team sets up headless CMS integrations with Next.js that make the editorial workflow as smooth as the production performance.

What we deliver:

  • Draft Mode API routes with secure token validation
  • Contentful and Sanity preview client setup
  • ISR cache invalidation via webhook on publish
  • Editor-facing preview banner with one-click exit
  • Custom CMS preview URL generation with expiry

Talk to our team about your CMS integration โ†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.