Client Handoff: Visual Editing
Wire Sanity Presentation Tool to Next.js for click-to-edit client previews.
Introduction
30 Second Summary
When you hand a website to a client, they need to update content without calling you every time. The ideal experience lets them click directly on the live page and edit text right there.
In this project, you will build a visual editing integration that connects Sanity Studio to your Next.js frontend. Clients will click any text on their live site preview to jump straight to the editable field in the CMS.
What You'll Build
A complete visual editing layer powered by Draft Mode route handlers, the Presentation Tool iframe architecture, and schema-level content governance.
By the end of this project, you'll have:
- Secure Draft Mode route handlers that authenticate Presentation Tool sessions and gate draft content behind verified Studio requests.
- Click-to-edit overlays that let editors click any text on the live preview to navigate directly to the corresponding Studio field.
- Schema governance that locks structural layout fields from visual editing while keeping text and media fields editable inline.
- Secret Mission: Add drag-and-drop array reordering so clients can rearrange page sections visually from the preview.
Do I need to complete Project 2 first?
Yes. This project builds directly on your existing Next.js 16 app with embedded Sanity Studio at /studio, defineLive and sanityFetch configured, and a working Vercel deployment.
You also need a SANITY_API_READ_TOKEN with Viewer permissions already set up in your environment variables.
Configure Draft Mode Route Handlers
The Draft Mode enable route is the secure handshake between Sanity Studio and your Next.js app. When an editor opens the Presentation Tool, the Studio makes a GET request to this endpoint with authentication parameters.
In this step, you will create the enable route, the disable route, and a client component that shows an "Exit Preview" button when browsing the site in Draft Mode outside the Studio.
In this step, get ready to:
- Create the Draft Mode enable route handler.
- Create the Draft Mode disable route handler.
- Build a DisableDraftMode client component.
Create the Draft Mode enable route
Draft Mode in Next.js lets your app serve draft content for authenticated sessions. The defineEnableDraftMode helper from next-sanity handles verifying that the request came from a legitimate Studio session before activating Draft Mode.
- In Cursor's Explorer sidebar, right-click your src/app folder and select New Folder.
- Type api/draft-mode/enable as the folder path (Cursor creates nested folders automatically).
- Right-click the new enable folder and select New File.
- Name the file route.ts and paste the following code:
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
import { client } from '@/sanity/lib/client'
// Export a GET handler that verifies the Studio session
// before activating Draft Mode for this browser session
export const { GET } = defineEnableDraftMode({
client: client.withConfig({
token: process.env.SANITY_API_READ_TOKEN,
}),
})
What does this code do?
defineEnableDraftMode creates a GET handler that uses @sanity/preview-url-secret internally to verify the request came from a legitimate Studio session.
client.withConfig({ token: ... }) provides an authenticated client that can query draft documents where preview secrets are stored. If the secret is invalid or missing, the route returns a 401. If valid, it activates Draft Mode and sets the appropriate cookies.
- Save src/app/api/draft-mode/enable/route.ts.
- Open your browser and visit http://localhost:3000/api/draft-mode/enable directly.
- You should see a 401 response with the text Invalid secret. This confirms the route is active and rejecting unauthenticated requests.
Seeing a 404 instead of 401?
- Make sure your dev server is still running. If it stopped, restart it with npm run dev.
- Confirm the file is at exactly src/app/api/draft-mode/enable/route.ts. Next.js file-based routing requires this exact folder structure to register the API route.
- Check that the file exports GET (not a default export). Next.js App Router requires named exports for HTTP methods.
Why isn't my enable route returning a 401?
Create the Draft Mode disable route
The disable route lets users exit Draft Mode when they are browsing the site directly (outside the Presentation Tool). It simply turns off Draft Mode and redirects back to the homepage.
- Right-click the src/app/api/draft-mode folder and select New Folder.
- Name the folder disable.
- Right-click the disable folder and create a new file called route.ts.
- Paste the following code:
import { draftMode } from 'next/headers'
import { NextResponse } from 'next/server'
// Disable Draft Mode and redirect to the homepage
export async function GET() {
;(await draftMode()).disable()
return NextResponse.redirect(new URL('/', process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'))
}
What does this code do?
(await draftMode()).disable() turns off Draft Mode for the current browser session. In Next.js 16, draftMode() is async and must be awaited.
The redirect sends the user back to the homepage so they see the published (non-draft) content again. The NEXT_PUBLIC_SITE_URL fallback to http://localhost:3000 ensures this works both locally and in production.
- Save src/app/api/draft-mode/disable/route.ts.
- You will not see a change yet. The disable route activates when a user clicks the Exit Preview button you will build next.
Create the DisableDraftMode client component
You need a visible button that lets editors exit Draft Mode when they are viewing the site directly in a browser tab. However, this button should be hidden when the site is loaded inside the Presentation Tool iframe, because the Studio controls Draft Mode there.
- In Cursor's Explorer sidebar, right-click the src/components folder and select New File.
- Name the file disable-draft-mode.tsx and paste the following code:
'use client'
import { useIsPresentationTool } from 'next-sanity/hooks'
// Renders an "Exit Preview" button only when viewing
// the site directly in Draft Mode (not inside the Studio iframe)
export function DisableDraftMode() {
const isPresentationTool = useIsPresentationTool()
// Hide the button when inside the Presentation Tool
if (isPresentationTool) return null
return (
<a
href="/api/draft-mode/disable"
className="fixed bottom-4 right-4 z-50 rounded-full bg-gray-900 px-4 py-2 text-sm text-white shadow-lg hover:bg-gray-700 transition-colors"
>
Exit Preview
</a>
)
}
What does this code do?
useIsPresentationTool from next-sanity/hooks detects whether the page is currently loaded inside the Sanity Presentation Tool iframe. If it is, the component returns null (no button rendered).
When a user is in Draft Mode outside the Studio (for example, they navigated directly to the site after a preview session), the button appears in the bottom-right corner. Clicking it hits the disable route, ending their draft session.
- Save src/components/disable-draft-mode.tsx.
- You will not see this button yet. It needs to be imported into your root layout, which you will do in the next step.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created in this step.
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
import { client } from '@/sanity/lib/client'
export const { GET } = defineEnableDraftMode({
client: client.withConfig({
token: process.env.SANITY_API_READ_TOKEN,
}),
})
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_SITE_URL || 'http://localhost:3000'))
}
'use client'
import { useIsPresentationTool } from 'next-sanity/hooks'
export function DisableDraftMode() {
const isPresentationTool = useIsPresentationTool()
if (isPresentationTool) return null
return (
<a
href="/api/draft-mode/disable"
className="fixed bottom-4 right-4 z-50 rounded-full bg-gray-900 px-4 py-2 text-sm text-white shadow-lg hover:bg-gray-700 transition-colors"
>
Exit Preview
</a>
)
}
Your Draft Mode route handlers are locked and loaded. Next up, you will wire the Visual Editing components into your root layout so Draft Mode actually renders click-to-edit overlays on your pages.
Wire Visual Editing into the Root Layout
Your Draft Mode route handlers are in place and rejecting unauthenticated requests. The next piece is connecting those routes to the components that actually render the visual editing experience.
The root layout is where two critical components live. SanityLive handles real-time content subscriptions for every visitor. VisualEditing scans the DOM for stega-encoded strings and draws click-to-edit overlays, but only when Draft Mode is active.
In this step, get ready to:
- Update your root layout to conditionally render visual editing components.
- Understand the v13 action prop default behavior on SanityLive.
- Confirm your Sanity client's stega.studioUrl setting points to your embedded Studio.
Update the root layout with visual editing components
Your layout needs to read the current Draft Mode state and conditionally render VisualEditing (the overlay engine) and DisableDraftMode (the exit button you built in Step 1). SanityLive stays outside the conditional so every visitor benefits from real-time subscriptions.
- Open src/app/layout.tsx in Cursor.
- Replace the entire contents of the file with the following code:
import { draftMode } from 'next/headers'
import { VisualEditing } from 'next-sanity/visual-editing'
import { SanityLive } from '@/sanity/lib/live'
import { DisableDraftMode } from '@/components/disable-draft-mode'
import '@/app/globals.css'
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
// Read Draft Mode state (async in Next.js 16)
const { isEnabled: isDraftMode } = await draftMode()
return (
<html lang="en">
<body>
{children}
{/* SanityLive always renders for real-time content subscriptions */}
<SanityLive />
{/* VisualEditing and DisableDraftMode only render in Draft Mode */}
{isDraftMode && (
<>
<VisualEditing />
<DisableDraftMode />
</>
)}
</body>
</html>
)
}
What does this code do?
- draftMode() is async in Next.js 16. It returns whether Draft Mode is currently enabled for this browser session.
- SanityLive renders unconditionally. It connects your app to the Sanity Live Content API so published changes propagate to all visitors.
- VisualEditing only renders in Draft Mode. It scans the DOM for invisible stega-encoded characters that Sanity embeds in string fields, then draws clickable overlays on top of those elements.
- DisableDraftMode shows the "Exit Preview" button you built in Step 1. It hides itself inside the Presentation Tool iframe (where the Studio controls Draft Mode instead).
- Save src/app/layout.tsx.
- Switch back to your terminal running next dev. Confirm there are no compilation errors in the console output.
Seeing a compilation error?
- Check that @/sanity/lib/live exports SanityLive (not just sanityFetch). Your live.ts from Project 2 should already export both via defineLive.
- Ensure the function is marked async. Without async, the await draftMode() call will fail.
- Verify the import path @/components/disable-draft-mode matches the file you created in Step 1.
Why isn't my layout compiling after adding VisualEditing and SanityLive?
✔️ Awesome, I've got everything!
Great. Double check you have saved src/app/layout.tsx and your dev server is running without errors.
ⓧ I'd like to double check the full code
import { draftMode } from 'next/headers'
import { VisualEditing } from 'next-sanity/visual-editing'
import { SanityLive } from '@/sanity/lib/live'
import { DisableDraftMode } from '@/components/disable-draft-mode'
import '@/app/globals.css'
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const { isEnabled: isDraftMode } = await draftMode()
return (
<html lang="en">
<body>
{children}
<SanityLive />
{isDraftMode && (
<>
<VisualEditing />
<DisableDraftMode />
</>
)}
</body>
</html>
)
}
Understand the v13 action prop on SanityLive
Why you don't need to configure the action prop
In next-sanity v13, SanityLive replaced the old revalidateSyncTags prop with an action prop. The default behavior revalidates cache tags when content changes occur.
For Draft Mode preview this default is fine. When Draft Mode is enabled, SanityLive automatically includes drafts and defaults the action to 'refresh', which triggers a router.refresh() so the editor always sees fresh content. No configuration needed on your part.
If you later need guaranteed instant updates for published content across CDNs, you can pair SanityLive with a Sync Tag Invalidate Function and set waitFor="function". For this project, the defaults handle everything.
Confirm stega.studioUrl points to your embedded Studio
The VisualEditing component uses stega.studioUrl from your Sanity client config to know where to navigate when an editor clicks a stega-encoded element. Since your Studio is embedded at /studio, this must be a relative path rather than a full URL.
- Open src/sanity/lib/client.ts in Cursor.
- Confirm that stega.studioUrl is set to '/studio'.
Why '/studio' and not a full URL?
For embedded Studio setups where the Studio lives at /studio inside the same Next.js app, a relative path keeps the overlay navigation working in both local development and production. A full URL like http://localhost:3333 would break in deployed environments.
- If stega.studioUrl is already '/studio', no changes are needed. Close the file.
Your layout is now wired for visual editing. Next up, you will configure the Presentation Tool so Sanity Studio can actually activate Draft Mode and load your site inside its preview iframe.
Configure the Presentation Tool and Document Locations
Your Draft Mode route handlers are in place, and the root layout renders overlays when Draft Mode is active. The missing piece is telling Sanity Studio how to reach your frontend and where each document lives on the site.
The Presentation Tool is a Studio plugin that renders your Next.js app inside an iframe. When an editor opens it, the plugin calls your Draft Mode enable route to authenticate, then draws click-to-edit overlays over every stega-encoded string in the preview. You will configure this plugin and create a resolver that maps document types to frontend URLs.
In this step, get ready to:
- Add the Presentation Tool plugin to your Studio configuration.
- Create a document location resolver that maps pages and posts to frontend routes.
- Verify click-to-edit overlays appear in the Presentation Tool iframe.
Add presentationTool to the Studio plugins
The presentationTool plugin needs one critical piece of configuration: the path to your Draft Mode enable route. Since your Studio is embedded in the same Next.js app at /studio, the iframe origin is implicit and you can omit it.
- Open sanity.config.ts in the root of your project.
- Replace the entire file contents with the following code:
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { presentationTool } from 'sanity/presentation'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './src/sanity/schemaTypes'
export default defineConfig({
name: 'default',
title: 'Agency Studio',
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
plugins: [
structureTool(),
// Presentation Tool embeds your frontend in an iframe for live editing
presentationTool({
previewUrl: {
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
visionTool(),
],
schema: {
types: schemaTypes,
},
})
What does this code do?
The presentationTool() call registers the Presentation Tool as a Studio plugin. The previewMode.enable path tells Studio which endpoint to call when it needs to activate Draft Mode in the iframe.
Because your Studio is embedded at /studio (same origin as the Next.js app), you do not need an origin field. Separate-app setups would need origin: 'http://localhost:3000' here, but yours is implicit.
- Save sanity.config.ts.
Studio not loading after the change?
Check for a missing comma between plugins in the array. Each plugin call (structureTool(), presentationTool(...), visionTool()) must be separated by a comma.
If your terminal shows an import error for sanity/presentation, confirm your sanity package is v3 or later by checking package.json.
Help me fix a Studio crash after adding presentationTool
Create the document location resolver
A document location resolver tells the Presentation Tool which frontend URL corresponds to each document type. Without it, the Studio cannot map a page document to its rendered route.
- Create the folder src/sanity/presentation/ if it does not already exist.
- Create a new file at src/sanity/presentation/resolve.ts and paste the following code:
import { defineLocations, type PresentationPluginOptions } from 'sanity/presentation'
// Maps document types to their frontend routes for click-to-edit navigation
export const resolve: PresentationPluginOptions['resolve'] = {
locations: {
// Page documents resolve to their slug-based route and also appear on Home
page: defineLocations({
select: {
title: 'title',
slug: 'slug.current',
},
resolve: (doc) => ({
locations: [
{
title: doc?.title || 'Untitled',
href: `/${doc?.slug}`,
},
{ title: 'Home', href: '/' },
],
}),
}),
// Post documents resolve to /posts/[slug] and also appear on All Posts
post: defineLocations({
select: {
title: 'title',
slug: 'slug.current',
},
resolve: (doc) => ({
locations: [
{
title: doc?.title || 'Untitled',
href: `/posts/${doc?.slug}`,
},
{ title: 'All Posts', href: '/posts' },
],
}),
}),
},
}
What does this code do?
Each defineLocations call declares two things: which fields to select from the document (title and slug), and a resolve function that builds URLs from those fields.
The locations array can list multiple routes where a document appears. The first entry is the primary route. Secondary entries (like Home or All Posts) let the Presentation Tool show the document on listing pages too.
- Save src/sanity/presentation/resolve.ts.
Wire the resolver into the Presentation Tool configuration
Now that the resolver file exists, you need to import it into sanity.config.ts and pass it to the presentationTool plugin.
- In sanity.config.ts, find the line that says:
import { schemaTypes } from './src/sanity/schemaTypes'
- Add this import directly below it:
import { resolve } from './src/sanity/presentation/resolve'
- Next, find the presentationTool({ line and add resolve as the first property inside the config object, so the block looks like this:
presentationTool({
resolve,
previewUrl: {
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
Why pass resolve here?
The resolve property tells the Presentation Tool how to navigate between documents and frontend URLs. When an editor opens a document, the tool checks this resolver to determine which iframe URL to load. When an editor clicks a stega-encoded element in the preview, the resolver maps it back to the correct Studio document.
- Save sanity.config.ts.
- Return to your browser and navigate to http://localhost:3000/studio. Click the Presentation icon in the Studio toolbar (or navigate directly to /studio/presentation).
- The iframe should load your site with Draft Mode active. Click any text element in the preview. The Studio should navigate to the corresponding document and focus the exact field you clicked.
Seeing a blank iframe or no overlays?
If the iframe is blank, confirm your Next.js dev server is still running. The Presentation Tool loads http://localhost:3000 in the iframe, so the app must be serving.
If the iframe loads but overlays do not appear, verify that stega.studioUrl in your client.ts is set to '/studio'. This tells the overlay system where to send click events.
If you see 403 errors in the browser console, your Sanity project may be missing a CORS origin. Go to sanity.io/manage and add http://localhost:3000 under API > CORS Origins with Allow credentials enabled.
Help me troubleshoot my Presentation Tool iframe
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the final state of both files after this step:
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { presentationTool } from 'sanity/presentation'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './src/sanity/schemaTypes'
import { resolve } from './src/sanity/presentation/resolve'
export default defineConfig({
name: 'default',
title: 'Agency Studio',
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
plugins: [
structureTool(),
presentationTool({
resolve,
previewUrl: {
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
visionTool(),
],
schema: {
types: schemaTypes,
},
})
import { defineLocations, type PresentationPluginOptions } from 'sanity/presentation'
export const resolve: PresentationPluginOptions['resolve'] = {
locations: {
page: defineLocations({
select: {
title: 'title',
slug: 'slug.current',
},
resolve: (doc) => ({
locations: [
{
title: doc?.title || 'Untitled',
href: `/${doc?.slug}`,
},
{ title: 'Home', href: '/' },
],
}),
}),
post: defineLocations({
select: {
title: 'title',
slug: 'slug.current',
},
resolve: (doc) => ({
locations: [
{
title: doc?.title || 'Untitled',
href: `/posts/${doc?.slug}`,
},
{ title: 'All Posts', href: '/posts' },
],
}),
}),
},
}
Your Presentation Tool is live. Editors can now click any text in the preview to jump straight to the field in Studio. Next up, you will lock down your schema so clients can edit content freely without breaking structural layout fields.
Implement Schema Governance for Client Safety
Your visual editing setup is now fully functional. Editors can click any text in the preview to jump straight to the corresponding field in Studio.
But here is the problem: clients can also click on structural elements like layout containers and grid wrappers. One accidental edit to a layout field could break the entire page. Schema governance solves this by separating fields into two groups and locking down the dangerous ones.
In this step, get ready to:
- Structure your page schema into layout and content fieldsets with role-based locking.
- Create a dynamic page route that strips stega encoding from structural values before conditional rendering.
- Set up a Content Editor role that restricts write access to content fields only.
Structure the page schema with fieldsets and readOnly
The governance strategy splits every field into one of two categories. "Layout" fields control page structure (which template renders, how many grid columns display). "Content" fields hold the text, images, and sections that clients should edit freely.
- Create a new file at src/sanity/schemaTypes/page.ts by running this command from your project root:
- If the src/sanity/schemaTypes directory already exists from Project 2, create only the file.
touch src/sanity/schemaTypes/page.ts
- Add the page schema definition with fieldsets and role-based readOnly rules by pasting this code into src/sanity/schemaTypes/page.ts:
import { defineType, defineField } from 'sanity'
export const page = defineType({
name: 'page',
title: 'Page',
type: 'document',
fieldsets: [
{ name: 'layout', title: 'Layout Configuration', options: { collapsible: true, collapsed: true } },
{ name: 'content', title: 'Page Content' },
],
fields: [
defineField({
name: 'title',
title: 'Page Title',
type: 'string',
fieldset: 'content',
validation: (rule) => rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
fieldset: 'content',
options: { source: 'title' },
validation: (rule) => rule.required(),
}),
defineField({
name: 'layoutType',
title: 'Layout Type',
type: 'string',
fieldset: 'layout',
options: {
list: [
{ title: 'Full Width', value: 'full-width' },
{ title: 'Sidebar', value: 'sidebar' },
{ title: 'Narrow', value: 'narrow' },
],
},
readOnly: ({ currentUser }) => {
return !currentUser?.roles.some((role) => role.name === 'administrator')
},
}),
defineField({
name: 'gridColumns',
title: 'Grid Columns',
type: 'number',
fieldset: 'layout',
options: {
list: [1, 2, 3, 4],
},
readOnly: ({ currentUser }) => {
return !currentUser?.roles.some((role) => role.name === 'administrator')
},
}),
defineField({
name: 'heading',
title: 'Heading',
type: 'string',
fieldset: 'content',
}),
defineField({
name: 'body',
title: 'Body',
type: 'blockContent',
fieldset: 'content',
}),
defineField({
name: 'sections',
title: 'Page Sections',
type: 'array',
fieldset: 'content',
of: [
{ type: 'hero' },
{ type: 'textBlock' },
{ type: 'callToAction' },
{ type: 'imageGallery' },
],
}),
],
})
What does this schema do?
The fieldsets array creates two visual groups in Studio: a collapsible "Layout Configuration" section and an always-visible "Page Content" section. This helps editors understand what they should and should not touch.
The readOnly callback on layoutType and gridColumns checks the current user's roles. If the user is not an administrator, those fields become read-only in Studio. Content fields like heading and body remain editable for everyone.
- Save src/sanity/schemaTypes/page.ts.
- Make sure you have registered this schema in your schemaTypes index file so Studio picks it up. Your dev server should restart without errors.
Seeing a schema registration error?
If Studio reports an unknown type for page, make sure you exported the page schema from your schemaTypes/index.ts file and included it in the schemaTypes array. Also check that blockContent is a registered type in your project (it is a common Sanity starter schema).
Help me debug my page schema registration error.
Control stega emission at the query level
Stega encoding embeds invisible characters in string values so the overlay system knows which field each piece of text came from. But when you use a string value in a conditional expression (like choosing which CSS class to render), those invisible characters break the comparison.
The solution is stegaClean() from next-sanity. It strips stega encoding before you compare the value. You also pass stega: false to queries that feed metadata or static generation, since those values never render visually.
- Create the dynamic page route at src/app/[slug]/page.tsx by running:
mkdir -p src/app/\[slug\]
touch src/app/\[slug\]/page.tsx
- Add the page route with stega-safe queries by pasting this code into src/app/[slug]/page.tsx:
import { notFound } from 'next/navigation'
import { sanityFetch } from '@/sanity/lib/live'
import { defineQuery, stegaClean } from 'next-sanity'
const PAGE_QUERY = defineQuery(`
*[_type == "page" && slug.current == $slug][0] {
_id,
title,
"slug": slug.current,
layoutType,
gridColumns,
heading,
body,
sections[]{ _key, _type, ... }
}
`)
const PAGE_SLUGS_QUERY = defineQuery(`
*[_type == "page" && defined(slug.current)]{ "slug": slug.current }
`)
type Props = {
params: Promise<{ slug: string }>
}
export async function generateStaticParams() {
const { data } = await sanityFetch({
query: PAGE_SLUGS_QUERY,
perspective: 'published',
stega: false,
})
return data
}
export async function generateMetadata({ params }: Props) {
const { data } = await sanityFetch({
query: PAGE_QUERY,
params: await params,
stega: false,
})
return { title: data?.title ?? 'Page not found' }
}
export default async function PageRoute({ params }: Props) {
const { data: page } = await sanityFetch({
query: PAGE_QUERY,
params: await params,
})
if (!page) notFound()
const layoutType = stegaClean(page.layoutType)
return (
<main className={layoutType === 'narrow' ? 'max-w-2xl mx-auto' : layoutType === 'sidebar' ? 'grid grid-cols-[1fr_300px]' : 'w-full'}>
<article>
<h1 className="text-4xl font-bold mb-6">{page.heading}</h1>
{/* Render body and sections here */}
</article>
</main>
)
}
What does stegaClean do here?
The generateStaticParams and generateMetadata functions both pass stega: false because their output never renders as visible text. Stega encoding in a <title> tag or a URL slug would corrupt those values.
The main PageRoute function fetches WITH stega enabled (the default) so that text fields like page.heading get click-to-edit overlays. But layoutType is used in a conditional comparison, so stegaClean(page.layoutType) strips the encoding before comparing it to 'narrow' or 'sidebar'. This also prevents click-to-edit overlays from appearing on the layout value.
- Save src/app/[slug]/page.tsx.
- Create a test page document in Studio with a slug (e.g. test), then visit http://localhost:3000/test in your browser. You should see the heading rendered with the layout class applied based on the layoutType field value.
Page returning 404?
Make sure you created a page document in Studio with a slug field value that matches the URL you are visiting. The slug must be published (not just a draft) unless you have Draft Mode active.
Also verify your sanityFetch import path matches your existing src/sanity/lib/live.ts export from Project 2.
Why is my dynamic page route returning a 404 in Next.js?
Create a Content Editor role for client safety
The schema-level readOnly callback is the enforcement mechanism. It checks the logged-in user's role and locks structural fields for anyone who is not an administrator. Now you need to create the role that your clients will actually use.
- Open sanity.io/manage in your browser and select your project.
- Click the Access tab in the left sidebar.
- Select Roles in the navigation.
- Click the button to create a new custom role.
- Name the role Content Editor and give it a description like "Write access to content fields only."
- In the Content Permissions section, grant Update and Create access to all documents. This allows editors to read and write content.
- Save the role.
How does this role work with the schema?
The Content Editor role gives users broad write access at the Content Lake level. The schema's readOnly callback then adds a second layer of protection inside Studio. Even though the role technically allows writes to all documents, the readOnly check on layoutType and gridColumns prevents non-administrators from editing those specific fields in the UI.
This two-layer approach means clients see a clean editing experience. Content fields are fully editable. Structural fields appear greyed out with no click-to-edit overlays in the Presentation Tool.
Custom roles require an Enterprise plan
Creating custom roles in the Sanity management console is an Enterprise plan feature. If you are on a free or Team plan, the readOnly schema pattern still works. You can assign your clients the built-in Editor role, and the schema callbacks will still lock structural fields since those users do not have the administrator role.
- Open the Presentation Tool in your Studio by navigating to the presentation view.
- Click on heading text in the preview iframe. Studio should navigate to the heading field and allow editing.
- Verify that no click-to-edit overlay appears on the layout wrapper element (the <main> tag) since layoutType was cleaned of stega encoding.
Still seeing overlays on structural elements?
Double check that stegaClean is applied to any field value used in conditional logic or className computation. If you use page.layoutType directly (without cleaning), stega encoding remains in the rendered HTML and the overlay system detects it as an editable element.
Why are click-to-edit overlays still appearing on my layout elements?
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/sanity/schemaTypes/page.ts and src/app/[slug]/page.tsx.
ⓧ I'd like to double check the full code
Here is the complete code for both files created in this step:
import { defineType, defineField } from 'sanity'
export const page = defineType({
name: 'page',
title: 'Page',
type: 'document',
fieldsets: [
{ name: 'layout', title: 'Layout Configuration', options: { collapsible: true, collapsed: true } },
{ name: 'content', title: 'Page Content' },
],
fields: [
defineField({
name: 'title',
title: 'Page Title',
type: 'string',
fieldset: 'content',
validation: (rule) => rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
fieldset: 'content',
options: { source: 'title' },
validation: (rule) => rule.required(),
}),
defineField({
name: 'layoutType',
title: 'Layout Type',
type: 'string',
fieldset: 'layout',
options: {
list: [
{ title: 'Full Width', value: 'full-width' },
{ title: 'Sidebar', value: 'sidebar' },
{ title: 'Narrow', value: 'narrow' },
],
},
readOnly: ({ currentUser }) => {
return !currentUser?.roles.some((role) => role.name === 'administrator')
},
}),
defineField({
name: 'gridColumns',
title: 'Grid Columns',
type: 'number',
fieldset: 'layout',
options: {
list: [1, 2, 3, 4],
},
readOnly: ({ currentUser }) => {
return !currentUser?.roles.some((role) => role.name === 'administrator')
},
}),
defineField({
name: 'heading',
title: 'Heading',
type: 'string',
fieldset: 'content',
}),
defineField({
name: 'body',
title: 'Body',
type: 'blockContent',
fieldset: 'content',
}),
defineField({
name: 'sections',
title: 'Page Sections',
type: 'array',
fieldset: 'content',
of: [
{ type: 'hero' },
{ type: 'textBlock' },
{ type: 'callToAction' },
{ type: 'imageGallery' },
],
}),
],
})
import { notFound } from 'next/navigation'
import { sanityFetch } from '@/sanity/lib/live'
import { defineQuery, stegaClean } from 'next-sanity'
const PAGE_QUERY = defineQuery(`
*[_type == "page" && slug.current == $slug][0] {
_id,
title,
"slug": slug.current,
layoutType,
gridColumns,
heading,
body,
sections[]{ _key, _type, ... }
}
`)
const PAGE_SLUGS_QUERY = defineQuery(`
*[_type == "page" && defined(slug.current)]{ "slug": slug.current }
`)
type Props = {
params: Promise<{ slug: string }>
}
export async function generateStaticParams() {
const { data } = await sanityFetch({
query: PAGE_SLUGS_QUERY,
perspective: 'published',
stega: false,
})
return data
}
export async function generateMetadata({ params }: Props) {
const { data } = await sanityFetch({
query: PAGE_QUERY,
params: await params,
stega: false,
})
return { title: data?.title ?? 'Page not found' }
}
export default async function PageRoute({ params }: Props) {
const { data: page } = await sanityFetch({
query: PAGE_QUERY,
params: await params,
})
if (!page) notFound()
const layoutType = stegaClean(page.layoutType)
return (
<main className={layoutType === 'narrow' ? 'max-w-2xl mx-auto' : layoutType === 'sidebar' ? 'grid grid-cols-[1fr_300px]' : 'w-full'}>
<article>
<h1 className="text-4xl font-bold mb-6">{page.heading}</h1>
{/* Render body and sections here */}
</article>
</main>
)
}
Your schema governance layer is complete. Clients can freely edit headings, body content, and page sections through the Presentation Tool. Structural fields like layout type and grid columns stay locked down, preventing accidental breakage of the page architecture.
Secret mission
Add Drag-and-Drop Array Reordering
Your clients can already click text to edit it. But what if they could drag entire page sections to rearrange them visually? This secret mission adds interactive page building where clients reorder content blocks by dragging them in the live preview.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely within free tiers, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Sanity API token (Viewer role): free, no ongoing cost.
- Vercel deployment (Hobby tier): free.
- Content Editor role in Sanity project: free, no ongoing cost.
- Local project files (Next.js app with Visual Editing integration).
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the next project in this series.
- Your Sanity API token (Viewer role) incurs no charges.
- Your Vercel Hobby deployment stays free within its generous limits.
- The Visual Editing features only activate when an editor opens the Presentation Tool and enters Draft Mode. They do not consume resources outside of editing sessions.
- Your local dev server can stay running or be stopped and restarted anytime.
Pause - I'll come back to this later
Shut down running processes to free up local memory. Your cloud resources remain intact and free.
- Stop your local development server by pressing Ctrl+C in the terminal where next dev is running.
- Your Vercel deployment stays live at no cost. Visitors can still access the published site.
- Your Sanity project, API token, and Content Editor role remain active. No action needed.
- When you return, run npm run dev from your project folder to start the dev server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. Since this is Project 3 in a series, deleting these resources also removes work from earlier projects.
- Delete the Vercel deployment: go to vercel.com, select your project, click Settings, scroll to the bottom, and click Delete Project.
- Revoke the Sanity API token: go to sanity.io/manage, select your project, click API in the sidebar, find your Viewer token under Tokens, and click the delete button next to it.
- Remove the Content Editor role: in sanity.io/manage, select your project, click Members in the sidebar, then Roles. Find the Content Editor role and delete it.
- Delete your local project files by running this command from the folder that contains your project:
rm -rf your-project-folder
On Windows, use this command instead:
rmdir /s /q your-project-folder
That's a Wrap!
That's a Wrap!
Nice work! You've just built a complete visual editing experience that turns your Sanity Studio into a true WordPress replacement for non-technical clients.
You've learned how to:
- Built secure Draft Mode route handlers and wired VisualEditing overlays into the root layout for real-time click-to-edit previews.
- Configured the Presentation Tool with document-to-URL resolvers so editors can click any text on the live preview and jump directly to the corresponding Studio field.
- Implemented schema governance that locks structural layout fields from visual editing while keeping content fields fully editable inline.
- Secret Mission: Added drag-and-drop array reordering so clients can rearrange page sections visually from the Presentation Tool preview.
Ready to quiz yourself?