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.
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
- Editor clicks "Preview" in the CMS
- CMS sends a request to your
/api/draft/enableroute with a secret token - Next.js sets a
__prerender_bypasscookie in the response - Editor is redirected to the content URL
- For subsequent requests, Next.js detects the cookie and renders dynamically (bypassing cache)
draftMode().isEnabledreturnstruein Server Components- 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
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic Draft Mode setup | 1 dev | 0.5 day | $200โ400 |
| CMS integration (Contentful or Sanity) | 1 dev | 1โ2 days | $400โ800 |
| Full system (preview + webhook revalidation + custom tokens) | 1 dev | 3โ5 days | $1,000โ2,000 |
| Multi-CMS / headless commerce preview | 1โ2 devs | 1โ2 weeks | $2,500โ5,000 |
See Also
- Next.js App Router Caching Strategies
- Next.js Static Generation with ISR
- Next.js Server Components Patterns
- Next.js Performance Optimization
- Next.js Image Optimization
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.
About the Author
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.
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
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.