Skill 05 · Sanity Best Practices
Subchapter 5.24
references/visual-editing.mdMarkdown8 KBView on GitHub
The Studio plugin (sanity/presentation) that renders your front-end application inside an iframe in the Studio. It enables the “Edit” overlay and bidirectional navigation.
Invisible characters embedded in strings that tell the Presentation Tool which field in which document the content comes from.
Framework-agnostic or specific libraries that handle:
When Visual Editing is enabled, string fields will contain invisible characters. You MUST clean them before using the value for logic.
| Scenario | Clean? | Why |
|---|---|---|
Comparing strings (if (x === 'y')) | ✅ Yes | Stega breaks equality |
| Using as object keys | ✅ Yes | Keys won’t match |
| Using as HTML IDs | ✅ Yes | Invalid characters |
| Passing to third-party libraries | ✅ Yes | May validate input |
Rendering text (<h1>{title}</h1>) | ❌ No | Breaks click-to-edit |
Passing to <PortableText /> | ❌ No | Handles internally |
| Passing to image helpers | ❌ No | Handles internally |
import { stegaClean } from "@sanity/client/stega";
export function Layout({ align }: { align: string }) {
// Good: Clean before comparison
const cleanAlign = stegaClean(align);
return <div className={cleanAlign === 'center' ? 'mx-auto' : ''} />
}Store your read token in a dedicated file that throws if missing:
// src/sanity/lib/token.ts
export const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
throw new Error('Missing SANITY_API_READ_TOKEN')
}Never expose tokens in client bundles. Pass to defineLive for server/browser use only when Draft Mode is enabled.
File: sanity.config.ts
import { defineConfig } from 'sanity'
import { presentationTool } from 'sanity/presentation'
import { resolve } from '@/sanity/presentation/resolve'
export default defineConfig({
// ...
plugins: [
presentationTool({
resolve, // Document locations (see below)
previewUrl: {
// The front-end origin — required when the Studio runs standalone
origin: process.env.SANITY_STUDIO_PREVIEW_ORIGIN || 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
],
})Show where documents appear in the front-end — enables quick navigation between Structure and Presentation tools.
// src/sanity/presentation/resolve.ts
import { defineLocations, PresentationPluginOptions } from 'sanity/presentation'
export const resolve: PresentationPluginOptions['resolve'] = {
locations: {
post: defineLocations({
select: { title: 'title', slug: 'slug.current' },
resolve: (doc) => ({
locations: [
{ title: doc?.title || 'Untitled', href: `/posts/${doc?.slug}` },
{ title: 'Posts index', href: `/posts` },
],
}),
}),
// Add more document types as needed
},
}Render <VisualEditing /> in Draft Mode for click-to-edit overlays.
Next.js (App Router):
// layout.tsx
import { VisualEditing } from 'next-sanity/visual-editing'
import { draftMode } from 'next/headers'
import { DisableDraftMode } from '@/components/disable-draft-mode'
export default async function RootLayout({ children }) {
return (
<html>
<body>
{children}
{(await draftMode()).isEnabled && (
<>
<DisableDraftMode />
<VisualEditing />
</>
)}
</body>
</html>
)
}Useful for content authors to exit preview and see published content:
// src/components/disable-draft-mode.tsx
'use client'
import { useDraftModeEnvironment } from 'next-sanity/hooks'
export function DisableDraftMode() {
const environment = useDraftModeEnvironment()
// Only show outside of Presentation Tool
if (environment !== 'live' && environment !== 'unknown') return null
return (
<a href="/api/draft-mode/disable" className="fixed bottom-4 right-4 bg-gray-50 px-4 py-2">
Disable Draft Mode
</a>
)
}Remix/Svelte: See framework-specific rules for useLiveMode and enableVisualEditing patterns.
NEVER allow Stega strings in <head> tags (Title, Description, Canonical URLs). It destroys SEO rankings and looks broken in search results.
stega: false in generateMetadata.<title> or <meta>.// Next.js Example — disable stega at fetch level
export async function generateMetadata({ params }) {
const { data } = await sanityFetch({
query: SEO_QUERY,
stega: false // Critical
})
return { title: data.title }
}Alternative: If you can’t disable stega at the fetch level, clean explicitly:
import { stegaClean } from "@sanity/client/stega";
export async function generateMetadata({ params }) {
const { data } = await sanityFetch({ query: PAGE_QUERY })
return {
title: stegaClean(data.title),
description: stegaClean(data.description),
openGraph: { url: stegaClean(data.canonicalUrl) }
}
}For arrays (e.g., “Related Posts”), enable drag-and-drop in the preview using data-sanity attributes and useOptimistic:
import { createDataAttribute } from 'next-sanity'
import { useOptimistic } from 'next-sanity/hooks'
// Add data-sanity to array container
<ul data-sanity={createDataAttribute({ id: documentId, type: 'post', path: 'relatedPosts' }).toString()}>
{items.map((item) => (
<li key={item._key} data-sanity={createDataAttribute({
id: documentId, type: 'post', path: `relatedPosts[_key=="${item._key}"]`
}).toString()}>
{item.title}
</li>
))}
</ul>Key requirements:
_key for array itemsuseOptimistic hook for instant UI updates during mutationsBy default, editing a field in the Presentation Tool triggers a full page re-render. For pages with many components, this can feel sluggish. Presentation queries solve this by fetching only the specific block being edited.
Instead of:
You get:
_key:*[_id == $documentId][0]{
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
title, subtitle, image
}
}Use a presentation query hook in your component (e.g., usePresentationQuery in Next.js)
Fall back to initial props when not in presentation mode
This pattern works for both Page Builder blocks (pageBuilder[]) and Portable Text blocks (body[]).
See framework-specific rules for implementation:
nextjs.md (Section 9)page-builder.md (Section 5)portable-text.md (Section 7)| Framework | Loader Package | Key Components |
|---|---|---|
| Next.js | next-sanity | <VisualEditing />, defineLive, usePresentationQuery |
| Remix | @sanity/react-loader | createQueryStore, useLiveMode, enableVisualEditing |
| Svelte | @sanity/svelte-loader | createRequestHandler, useLiveMode, enableVisualEditing |
| Nuxt | @nuxtjs/sanity | Automatic via module config (visualEditing: {}) |
| Astro | @sanity/astro | sanity({ useCdn: false, stega: true }) |