Configuring the Presentation Tool
Configure the Presentation Tool: previewUrl, document location resolvers, allowed origins, components, and navigation for multi-environment setups.
The Presentation Tool is a Sanity Studio plugin that renders your frontend application inside an iframe, giving content editors a live preview with click-to-edit functionality. This guide covers how to configure it, set up document resolvers, handle multiple preview origins, and troubleshoot common issues.
All configuration happens in your sanity.config.ts file. The Presentation Tool works with any frontend that implements the visual editing protocol, regardless of framework. For an overview of how the Presentation Tool fits into the broader visual editing architecture, see the architecture overview.
Prerequisites
- A Sanity Studio project with
sanityv3 or later installed - A deployed (or locally running) frontend application
- CORS configured in your Sanity project to allow requests from your frontend origin
Basic setup
Install the Presentation Tool (included in the sanity package) and add it to your Studio configuration:
import { defineConfig } from 'sanity'
import { presentationTool } from 'sanity/presentation'
import { structureTool } from 'sanity/structure'
export default defineConfig({
name: 'default',
title: 'My Studio',
projectId: 'your-project-id',
dataset: 'production',
plugins: [
structureTool(),
presentationTool({
previewUrl: {
initial: 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
disable: '/api/draft-mode/disable',
},
},
}),
],
})This configuration tells the Presentation Tool to:
- Load your frontend at
http://localhost:3000in an iframe. - Call
/api/draft-mode/enableto activate draft mode when the preview opens. - Call
/api/draft-mode/disableto deactivate draft mode when the editor exits.
Configuration options
The presentationTool() function accepts these options:
| Option | Required | Description |
|---|---|---|
| `previewUrl` | Yes | Preview URL configuration (see below) |
| `resolve` | No | Document-to-URL mapping with `locations` and `mainDocuments` |
| `allowOrigins` | No | Allowed iframe origins for security |
| `name` | No | Tool name used in the Studio URL. Default: `presentation` |
| `title` | No | Display title in Studio navigation. Default: `Presentation` |
| `icon` | No | Custom icon component for the navigation |
| `components` | No | Customize the preview header or footer |
| `navigate` | No | Control navigation behavior in the preview iframe |
Preview URL configuration
The previewUrl option can be a string, an object, or a resolver function:
// Simple: just a URL
presentationTool({
previewUrl: 'http://localhost:3000',
})
// Full: URL with draft mode endpoints
presentationTool({
previewUrl: {
initial: 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
disable: '/api/draft-mode/disable',
},
},
})When previewMode is configured, the Presentation Tool automatically calls the enable endpoint when the preview opens and the disable endpoint when the editor navigates away. These endpoints are relative to the initial URL.
To generate preview URLs dynamically based on the current document, use resolve.mainDocuments (covered in the document location resolvers section below). This maps URL patterns to document types, so the Presentation Tool can navigate the preview to the right page when an editor selects a document.
Document location resolvers
Document location resolvers connect Sanity documents to frontend routes. They enable two features:
- Automatic document display: when an editor navigates to a URL in the preview, the Studio opens the corresponding document
- "Used on" links: the Studio shows editors where a document's content appears on the site
Main documents (defineDocuments)
Main documents resolve the primary document for a given URL. When the preview iframe navigates to a new page, the Presentation Tool matches the URL against your route patterns and opens the corresponding document in the editor pane.
import { defineConfig } from 'sanity'
import { presentationTool, defineDocuments } from 'sanity/presentation'
const mainDocuments = defineDocuments([
{
route: '/posts/:slug',
filter: `_type == "post" && slug.current == $slug`,
},
{
route: '/products/:slug',
filter: `_type == "product" && slug.current == $slug`,
},
{
route: '/products',
type: 'productsListing',
},
])
export default defineConfig({
// ...
plugins: [
presentationTool({
previewUrl: {
initial: 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
disable: '/api/draft-mode/disable',
},
},
resolve: {
mainDocuments,
},
}),
],
})Each entry in the array has:
route: a URL pattern with named parameters (for example,:slug,:year). Parameters are extracted and passed as GROQ query variables.filter: a GROQ filter expression that identifies the document. Use$paramNameto reference extracted URL parameters.type: shorthand forfilter: '_type == "typeName"'when no parameters are needed.
The Presentation Tool evaluates routes in order and uses the first match. Place more specific routes before general ones.
Route patterns with multiple parameters
Routes can include multiple parameters:
const mainDocuments = defineDocuments([
{
route: '/blog/:year/:month/:slug',
filter: `_type == "post" && slug.current == $slug`,
},
])All named parameters (:year, :month, :slug) are available as GROQ variables in the filter expression.
Document locations (defineLocations)
Document locations define where a document's content appears across your site. This powers the "Used on" panel in the Studio, showing editors all the pages that reference a given document.
import { defineConfig } from 'sanity'
import { presentationTool, defineLocations } from 'sanity/presentation'
const locations = {
post: defineLocations({
select: {
title: 'title',
slug: 'slug.current',
},
resolve: (doc) => ({
locations: [
{
title: doc?.title || 'Untitled',
href: `/posts/${doc?.slug}`,
},
{
title: 'All posts',
href: '/posts',
},
],
}),
}),
product: defineLocations({
select: {
title: 'title',
slug: 'slug.current',
category: 'category->slug.current',
},
resolve: (doc) => ({
locations: [
{
title: doc?.title || 'Untitled',
href: `/products/${doc?.slug}`,
},
{
title: 'Category page',
href: `/categories/${doc?.category}`,
},
{
title: 'All products',
href: '/products',
},
],
}),
}),
// For documents used globally (like site settings), use a message instead
siteSettings: defineLocations({
message: 'This document is used on all pages',
tone: 'caution',
}),
}
export default defineConfig({
// ...
plugins: [
presentationTool({
previewUrl: {
initial: 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
disable: '/api/draft-mode/disable',
},
},
resolve: {
locations,
},
}),
],
})The defineLocations function accepts:
select: a map of field names to GROQ projections. These fields are fetched from the document and passed to theresolvefunction.resolve: a function that receives the selected fields and returns an object with alocationsarray. Each location has atitleandhref.message: an optional string displayed instead of location links, useful for global documents like site settings.tone: visual tone for the message. Options:caution,positive,critical.
The resolve function is called reactively. As the editor changes document fields, the function re-runs and the location links update in real time. This means editors always see accurate "Used on" links, even for unsaved changes.
Combining resolvers
Use both mainDocuments and locations together for the best editing experience:
export default defineConfig({
// ...
plugins: [
presentationTool({
previewUrl: {
initial: 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
disable: '/api/draft-mode/disable',
},
},
resolve: {
mainDocuments,
locations,
},
}),
],
})Allowed origins
The allowOrigins option controls which frontend origins the Presentation Tool trusts for Comlink (postMessage) communication. This is a security measure that prevents unauthorized origins from exchanging messages with your Studio via the iframe.
presentationTool({
previewUrl: {
initial: 'https://my-site.com',
previewMode: {
enable: '/api/draft-mode/enable',
disable: '/api/draft-mode/disable',
},
},
allowOrigins: [
'http://localhost:3000',
'http://localhost:3001',
'https://my-site.com',
'https://staging.my-site.com',
],
})Origins support wildcard patterns for ports:
allowOrigins: [ 'http://localhost:*', // Any port on localhost 'https://my-site.com', // Exact match 'https://*.my-site.com', // Subdomains ]
If allowOrigins is not set, the Presentation Tool allows the origin from previewUrl.initial by default.
Security note: only add origins you trust. Any allowed origin can send postMessage events to the Studio, run live preview queries, and access draft content.
Multiple preview environments
For projects with staging and production environments, configure the preview URL dynamically:
presentationTool({
previewUrl: {
initial: process.env.SANITY_STUDIO_PREVIEW_URL || 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
disable: '/api/draft-mode/disable',
},
},
allowOrigins: [
'http://localhost:*',
'https://staging.my-site.com',
'https://my-site.com',
],
})Set the SANITY_STUDIO_PREVIEW_URL environment variable differently for each Studio deployment to point at the corresponding frontend environment.
Draft mode endpoints
Your frontend must implement two HTTP endpoints that the Presentation Tool calls to toggle draft mode. The Presentation Tool navigates the iframe to the enable URL with query parameters. Your endpoint validates the request, sets a cookie, and redirects the iframe to the preview page.
Enable endpoint
When the Presentation Tool opens, it navigates the iframe to your enable endpoint with a secret token and a redirect path as query parameters:
GET /api/draft-mode/enable?sanity-preview-secret=<token>&sanity-preview-pathname=<path>&sanity-preview-perspective=<perspective>Here's a framework-agnostic implementation using the Web API Request and Response objects:
import { validatePreviewUrl } from '@sanity/preview-url-secret'
import { withoutSecretSearchParams } from '@sanity/preview-url-secret/without-secret-search-params'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { client } from './sanity-client'
export async function handleEnableDraftMode(request: Request): Promise<Response> {
// validatePreviewUrl checks the secret against the Sanity API
const { isValid, redirectTo, studioPreviewPerspective } = await validatePreviewUrl(
client.withConfig({ token: process.env.SANITY_API_READ_TOKEN }),
request.url
)
if (!isValid) {
return new Response('Invalid secret', { status: 401 })
}
const cleanRedirect = redirectTo
? withoutSecretSearchParams(new URL(redirectTo, request.url)).pathname
: '/'
// Set the perspective cookie. Serves as both draft mode indicator and perspective value
const perspective = studioPreviewPerspective || 'drafts'
const headers = new Headers()
headers.append(
'Set-Cookie',
`${perspectiveCookieName}=${perspective}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=3600`
)
headers.set('Location', cleanRedirect)
return new Response(null, { status: 307, headers })
}The validatePreviewUrl function from @sanity/preview-url-secret verifies that the secret token was generated by the Presentation Tool. This prevents unauthorized users from activating draft mode.
Disable endpoint
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
export async function handleDisableDraftMode(request: Request): Promise<Response> {
return new Response(null, {
status: 307,
headers: {
'Set-Cookie': `${perspectiveCookieName}=; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=0`,
Location: '/',
},
})
}Checking draft mode status
In your application code, check the perspective cookie to determine whether to serve draft or published content. The cookie's presence indicates draft mode is active, and its value specifies the perspective:
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
function isDraftMode(request: Request): boolean {
const cookies = request.headers.get('Cookie') || ''
return cookies.includes(`${perspectiveCookieName}=`)
}
// Use the appropriate client configuration based on draft mode
const preview = isDraftMode(request)
const perspective = preview ? 'drafts' : 'published'
const data = await client
.withConfig({
perspective,
useCdn: !preview,
// Token required server-side to fetch draft/release content
...(preview && { token: process.env.SANITY_API_READ_TOKEN }),
})
.fetch(query, params)For a complete implementation, see the guide on implementing preview/draft mode.
Troubleshooting
The preview iframe shows a blank page
- Check CORS: your Sanity project must allow requests from the frontend origin. Verify this in your project's API settings at manage.sanity.io.
- Check the preview URL: confirm the
initialURL is correct and the frontend is running. - Check iframe restrictions: some hosting providers set
X-Frame-OptionsorContent-Security-Policyheaders that prevent embedding. Your frontend must allow being framed by your Studio's origin.
Click-to-edit overlays don't appear
- Stega encoding must be active: verify that your client has
stega: { enabled: true }and that draft mode is enabled. enableVisualEditing()must be called: your frontend needs to initialize the overlay system. Check that it runs when draft mode is active.- Check
allowOrigins: the frontend origin must be in the allowed list for Comlink messages to flow between the Studio and iframe.
Document doesn't open when clicking an element
- Content Source Maps must be present: the client must request them (automatic when stega is enabled). Verify by checking the network tab for
resultSourceMapin GROQ query responses. - Check the stega-encoded data: inspect the rendered HTML for zero-width characters in text content. If they're missing, stega encoding may not be active.
Navigation doesn't sync between Studio and preview
- Router integration required: your
enableVisualEditing()call must include ahistoryoption that wires up your router's navigation events. Without this, the Studio can't detect URL changes in the iframe. - Check
mainDocumentsconfiguration: if routes don't match, the Studio won't know which document corresponds to the current URL.
Live updates don't appear
- Draft mode must be active: live updates require the
draftsperspective withuseCdn: false. - Live Content API subscription required: your frontend must subscribe to content changes and trigger re-fetches. This is handled automatically by framework libraries but must be implemented manually in custom integrations.
- Check authentication: the Live Content API requires a valid token for draft content. Verify your token has read access to the dataset.
Next steps
- Setting up the Sanity client for visual editing: configure stega encoding and perspectives.
- Implementing preview/draft mode: build the enable/disable endpoints referenced in this guide.
- Enabling overlays and click-to-edit: integrate
@sanity/visual-editingwith your frontend.