Easily preview draft changes in a new tab, without all the fussy setup. This plugin adds a "Preview" button to configured document types in Sanity Studio that securely opens your frontend site with draft content enabled.
- 🔒 Secure preview access using
@sanity/preview-url-secretfor cryptographically secure tokens - ⚡ Simple configuration with sensible defaults
- 🎯 Per-document-type settings with custom URL prefixes
- 🔧 Flexible slug field configuration supporting dot notation
- 🎨 Native Sanity UI with toast notifications for errors
- 🧹 Automatic cleanup of expired preview secrets
npm install sanity-plugin-easy-previewYou'll also need to install @sanity/preview-url-secret for your frontend API route:
npm install @sanity/preview-url-secretAdd the plugin to your sanity.config.ts (or .js):
import {defineConfig} from 'sanity'
import {easyPreview} from 'sanity-plugin-easy-preview'
export default defineConfig({
// ...
plugins: [
easyPreview({
// Base URL of your preview site (required)
// Vite/Astro: use import.meta.env.SANITY_STUDIO_PREVIEW_URL
// previewUrl: import.meta.env.SANITY_STUDIO_PREVIEW_URL,
previewUrl: 'http://localhost:3000',
// API route path for draft validation (default: '/api/draft')
draftRoute: '/api/draft',
// Global default slug field (default: 'slug.current')
slugField: 'slug.current',
// Document types with preview enabled
types: [
{
type: 'page',
urlPrefix: '', // Homepage and pages at root level
},
{
type: 'blogPost',
urlPrefix: '/blog', // Blog posts prefixed with /blog
},
{
type: 'product',
urlPrefix: (doc) => `/products/${doc.category}`, // Dynamic prefix based on document
slugField: 'productSlug.current', // Override global slugField for this type
},
],
}),
],
})- Type:
string - Default: none (must be provided)
- The base URL (origin) of your frontend site
- In Studio configs built with Vite (Sanity v5, Astro, etc.), access env via
import.meta.env.SANITY_STUDIO_PREVIEW_URL - Avoid
process.envinsanity.config.tsfor browser-bundled Studio configs
- Type:
string - Default:
'/api/draft' - The API route path that handles preview validation
- Type:
string - Default:
'slug.current' - Global default path to the slug field in documents
- Supports dot notation:
'slug.current','meta.slug','pathname.current', etc. - Can be overridden per document type in the
typesconfiguration
- Type:
PreviewTypeConfig[] - Array of document types that should have preview enabled
Each type config has:
name(required): The schema type name (e.g.,'page','blogPost')urlPrefix(optional): URL prefix for this document type- Can be a string:
'/blog' - Or a function:
(doc) => '/products/' + doc.category
- Can be a string:
slugField(optional): Path to the slug field for this specific type- Overrides the global
slugFieldsetting - Supports dot notation:
'slug.current','meta.slug', etc. - If not set, uses the global
slugField(default:'slug.current')
- Overrides the global
You can set the preview URL via environment variable in .env.local:
SANITY_STUDIO_PREVIEW_URL=http://localhost:3000Then reference it in sanity.config.ts using import.meta.env.SANITY_STUDIO_PREVIEW_URL.
This is useful for different environments (development, staging, production).
Your frontend needs to validate preview requests and enable draft mode. Here's how to set it up for different frameworks:
Create a read token in your Sanity project settings (Manage → API → Tokens) with Viewer permissions and add it to your frontend .env.local:
SANITY_API_READ_TOKEN=sk...Create an API route at app/api/draft/route.ts:
import {draftMode} from 'next/headers'
import {redirect} from 'next/navigation'
import {validatePreviewUrl} from '@sanity/preview-url-secret'
import {client} from '@/sanity/lib/client' // Your Sanity client
const clientWithToken = client.withConfig({
token: process.env.SANITY_API_READ_TOKEN,
})
export async function GET(request: Request) {
const {isValid, redirectTo = '/'} = await validatePreviewUrl(clientWithToken, request.url)
if (!isValid) {
return new Response('Invalid secret', {status: 401})
}
draftMode().enable()
redirect(redirectTo)
}Create a disable route at app/api/disable-draft/route.ts:
import {draftMode} from 'next/headers'
import {NextRequest, NextResponse} from 'next/server'
export async function GET(request: NextRequest) {
draftMode().disable()
const url = new URL(request.nextUrl)
return NextResponse.redirect(new URL('/', url.origin))
}Create an API route at pages/api/draft.ts:
import type {NextApiRequest, NextApiResponse} from 'next'
import {validatePreviewUrl} from '@sanity/preview-url-secret'
import {client} from '@/lib/sanity.client'
const clientWithToken = client.withConfig({
token: process.env.SANITY_API_READ_TOKEN,
})
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const {isValid, redirectTo = '/'} = await validatePreviewUrl(clientWithToken, req.url!)
if (!isValid) {
return res.status(401).send('Invalid secret')
}
res.setDraftMode({enable: true})
res.writeHead(307, {Location: redirectTo})
res.end()
}Create an API route at src/pages/api/draft.ts:
import type {APIRoute} from 'astro'
import {validatePreviewUrl} from '@sanity/preview-url-secret'
import {getClient} from '@/lib/sanity'
const clientWithToken = getClient().withConfig({
token: import.meta.env.SANITY_API_READ_TOKEN,
})
export const GET: APIRoute = async ({request, cookies, redirect}) => {
const {isValid, redirectTo = '/'} = await validatePreviewUrl(clientWithToken, request.url)
if (!isValid) {
return new Response('Invalid secret', {status: 401})
}
// Set a secure cookie for draft mode
cookies.set('__draft', 'true', {
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
path: '/',
})
return redirect(redirectTo)
}Create a disable route at src/pages/api/disable-draft.ts:
import type {APIRoute} from 'astro'
export const GET: APIRoute = async ({cookies, redirect}) => {
// Delete the draft mode cookie
cookies.delete('__draft', {
path: '/',
})
return redirect('/')
}Once draft mode is enabled via the cookie, your data fetching code should use the drafts perspective:
import {draftMode} from 'next/headers'
import {client} from '@/sanity/lib/client'
export async function getPage(slug: string) {
const isDraft = draftMode().isEnabled
return client.fetch(
`*[_type == "page" && slug.current == $slug][0]`,
{slug},
{
perspective: isDraft ? 'drafts' : 'published',
},
)
}import {getClient} from '@/lib/sanity'
export async function getPage(slug: string, cookies: AstroCookies) {
const isDraft = Boolean(cookies.get('__draft')?.value)
return getClient().fetch(
`*[_type == "page" && slug.current == $slug][0]`,
{slug},
{
perspective: isDraft ? 'drafts' : 'published',
},
)
}For the examples above, here's how to set up your Sanity client:
Next.js (lib/sanity/client.ts or sanity/lib/client.ts):
import {createClient} from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: false, // Set to false for draft mode
})Astro (lib/sanity.ts):
import {sanityClient} from 'sanity:client'
export function getClient() {
return sanityClient
}Or if using @sanity/client directly:
import {createClient} from '@sanity/client'
const client = createClient({
projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID,
dataset: import.meta.env.PUBLIC_SANITY_DATASET,
apiVersion: '2024-01-01',
useCdn: false,
})
export function getClient() {
return client
}- User clicks Preview in Sanity Studio
- Plugin generates a cryptographically secure secret using WebCrypto API
- Secret is stored as a
sanity.previewUrlSecretdocument (draft) in your dataset with 1-hour TTL - User is redirected to
/api/draft?sanity-preview-secret=xxx&sanity-preview-pathname=/slug - Your API route validates the secret against the dataset
- If valid, a secure HTTP-only cookie is set
- User is redirected to the intended page
- Your frontend detects the cookie and fetches draft content using
perspective: 'drafts'
- ✅ Secrets are cryptographically random (16 bytes → base64url)
- ✅ Secrets stored as drafts only (never published)
- ✅ 1-hour TTL with automatic cleanup
- ✅ HTTP-only cookies prevent JavaScript access
- ✅ Secure flag in production (HTTPS only)
- ✅ SameSite=Lax protection
- ✅ Requires Contributor role to create secrets (can create drafts)
- ✅ Validation requires read token with Viewer permissions
- Check that the document type is in your
typesconfiguration - Verify the type
namematches exactly
- Ensure your document has the slug field configured in
slugField - Check that the slug path is correct (e.g.,
slug.currentvs justslug)
- Verify
SANITY_API_READ_TOKENis set in your frontend environment - Check that the token has Viewer or higher permissions
- Ensure your API route is correctly validating with
validatePreviewUrl
- Check that your data fetching uses
perspective: 'drafts'when draft mode is enabled - Verify the cookie is being set correctly
- Test by checking browser DevTools → Application → Cookies
- In Next.js, prefix with
NEXT_PUBLIC_only if used in browser code - For Astro, use
import.meta.env.SANITY_API_READ_TOKEN - Restart your dev server after adding environment variables
MIT © Christian (Chrish) Dunne
This plugin uses @sanity/plugin-kit with default configuration for build & watch scripts.
See Testing a plugin in Sanity Studio on how to run this plugin with hotreload in the studio.
Run "CI & Release" workflow. Make sure to select the main branch and check "Release new version".
Semantic release will only release on configured branches, so it is safe to run release on any branch.