Visual Editing

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 sanity v3.85.0 or later installed (Presentation ships in the sanity package from v3.20.0, but allowOrigins requires v3.85.0).
  • A deployed (or locally running) frontend application.
  • CORS configured in your Sanity project to allow requests from your frontend origin. Add origins under your project's API settings at manage.sanity.io.

Basic setup

Install the Presentation Tool (included in the sanity package) and add it to your Studio configuration:

This configuration tells the Presentation Tool to:

  • Load your frontend at http://localhost:3000 in an iframe.
  • Call /api/draft-mode/enable to activate draft mode when the preview opens.
  • Register /api/draft-mode/disable as an optional route editors can visit to exit draft mode. The Presentation Tool doesn't call it automatically.

Configuration options

The presentationTool() function accepts these options:

OptionRequiredDescription
`previewUrl`YesPreview URL configuration (see below)
`resolve`NoDocument-to-URL mapping with `locations` and `mainDocuments`
`allowOrigins`NoAllowed iframe origins for security
`name`NoTool name used in the Studio URL. Default: `presentation`
`title`NoDisplay title in Studio navigation. Default: `Presentation`
`icon`NoCustom icon component for the navigation
`components`NoCustomize the preview header or add a navigator sidebar (`unstable_header`, `unstable_navigator`)
`devMode`NoEnable development mode for debugging the Studio-to-preview connection

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. It never calls the disable endpoint: the disable option exists in the plugin's types but is marked as not yet implemented, so editors exit draft mode by visiting that route directly. Both 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 $paramName to reference extracted URL parameters.
  • type: shorthand for filter: '_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 the resolve function.
  • resolve: a function that receives the selected fields and returns an object with a locations array. Each location has a title and href.
  • 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. If the origins you list don't match the resolved initial preview URL, that URL's origin is added to the allow list automatically. A wildcard-only hostname (allowing any site) is rejected as insecure.

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

The Presentation Tool doesn't call this endpoint. Implement it as a route that editors (or your application) can visit directly to clear the perspective cookie and return to published content:

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 initial URL is correct and the frontend is running.
  • Check iframe restrictions: some hosting providers set X-Frame-Options or Content-Security-Policy headers 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: stega encodes editing metadata as invisible characters in the text your frontend renders. 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.

Copied text and stega encoding

The <VisualEditing /> component automatically strips stega encoding from clipboard data on copy events by default, so copied text is clean for end users with no additional configuration needed. Sanity Studio also automatically strips stega from text pasted into any primitive field, so stega encoding does not leak into Studio-edited content.

Document doesn't open when clicking an element

  • Content Source Maps must be present: Content Source Maps link each value in a query response back to its source document and field. The client must request them (automatic when stega is enabled). Verify by checking the network tab for resultSourceMap in 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 a history option that wires up your router's navigation events. Without this, the Studio can't detect URL changes in the iframe.
  • Check mainDocuments configuration: 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 drafts perspective with useCdn: 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

Was this page helpful?