# @sanity/visual-editing

> [![npm stat](https://img.shields.io/npm/dm/@sanity/visual-editing.svg?style=flat-square)](https://npm-stat.com/charts.html?package=@sanity/visual-editing) [![npm version](https://img.shields.io/npm/v/@sanity/visual-editing.svg?style=flat-square)](https://

Latest version **6.1.2** (published 2026-09-01) · MIT license · 0 weekly downloads

## Install

```sh
npm install @sanity/visual-editing
pnpm add @sanity/visual-editing
yarn add @sanity/visual-editing
bun add @sanity/visual-editing
```

## Health

**Score 75/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; has provenance; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 6.1.2 |
| Published | 2026-09-01 |
| First published | 2024-02-02 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=22.12 |
| Dependencies | 16 |
| Unpacked size | 1.2 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Provenance | attested (GitHub Actions) |
| GitHub stars | 64 |
| Author | Sanity.io |
| Maintainers | sanity-svc.npm, sanity-io |
| Keywords | overlays, presentation, preview, sanity.io, visual-editing |

## Links

- npm: https://www.npmjs.com/package/@sanity/visual-editing
- Repository: https://github.com/sanity-io/visual-editing
- Homepage: https://github.com/sanity-io/visual-editing/tree/main/packages/visual-editing#readme
- Issues: https://github.com/sanity-io/visual-editing/issues
- npm.io page: https://npm.io/package/@sanity/visual-editing

## Dependencies (16)

- [rxjs](https://npm.io/package/rxjs.md) ^7.8.2
- [dequal](https://npm.io/package/dequal.md) ^2.0.3
- [xstate](https://npm.io/package/xstate.md) ^5.32.6
- [react-is](https://npm.io/package/react-is.md) ^19.2.8
- [lodash-es](https://npm.io/package/lodash-es.md) ^4.18.1
- [@sanity/ui](https://npm.io/package/@sanity/ui.md) ^4.0.7
- [@sanity/icons](https://npm.io/package/@sanity/icons.md) ^5.2.1
- [@sanity/types](https://npm.io/package/@sanity/types.md) ^6.11.0
- [@vercel/stega](https://npm.io/package/@vercel/stega.md) ^1.1.0
- [@sanity/mutate](https://npm.io/package/@sanity/mutate.md) ^0.18.1
- [@sanity/comlink](https://npm.io/package/@sanity/comlink.md) ^4.0.3
- [@sanity/preview-url-secret](https://npm.io/package/@sanity/preview-url-secret.md) ^4.1.5
- [@sanity/visual-editing-csm](https://npm.io/package/@sanity/visual-editing-csm.md) ^3.0.18
- [scroll-into-view-if-needed](https://npm.io/package/scroll-into-view-if-needed.md) ^3.1.0
- [@sanity/presentation-comlink](https://npm.io/package/@sanity/presentation-comlink.md) ^2.2.3
- [@sanity/visual-editing-types](https://npm.io/package/@sanity/visual-editing-types.md) ^2.1.1

## Alternatives

- [react-native-root-siblings](https://npm.io/package/react-native-root-siblings.md) — 102.9K weekly downloads
- [@praxisui/dialog](https://npm.io/package/@praxisui/dialog.md) — 2.0K weekly downloads
- [easy-toggle-state](https://npm.io/package/easy-toggle-state.md) — 350 weekly downloads
- [ngx-lightbox-evp](https://npm.io/package/ngx-lightbox-evp.md) — 19 weekly downloads
- [react-native-modal-translucent-axton](https://npm.io/package/react-native-modal-translucent-axton.md) — 4 weekly downloads

## Recent versions

- 6.1.2 (latest) — 2026-09-01
- 5.0.0-canary.2 (canary) — 2025-11-03
- 2.12.3-release.0 (release) — 2025-01-16
- 6.1.1 — 2026-08-21
- 6.1.0 — 2026-08-20
- 6.0.4 — 2026-08-13
- 6.0.3 — 2026-08-13
- 6.0.2 — 2026-08-13
- 6.0.1 — 2026-08-11
- 6.0.0 — 2026-08-11
- 5.7.3 — 2026-07-24
- 5.7.2 — 2026-07-22
- 5.7.1 — 2026-07-22
- 5.7.0 — 2026-07-22
- 5.6.0 — 2026-07-21
- … 307 more at https://npm.io/package/@sanity/visual-editing/versions

## README

# @sanity/visual-editing

[![npm stat](https://img.shields.io/npm/dm/@sanity/visual-editing.svg?style=flat-square)](https://npm-stat.com/charts.html?package=@sanity/visual-editing)
[![npm version](https://img.shields.io/npm/v/@sanity/visual-editing.svg?style=flat-square)](https://www.npmjs.com/package/@sanity/visual-editing)
[![gzip size][gzip-badge]][bundlephobia]
[![size][size-badge]][bundlephobia]

This package is used with the [Presentation](https://www.sanity.io/docs/presentation) tool in the Sanity Studio to create clickable elements to take editors right from previews to the document and field they want to edit.

## Getting started

```sh
npm install @sanity/visual-editing react react-dom
```

> [!TIP]
> Building a Vue, Svelte, Astro, vanilla JavaScript, or other non-React application?
> Use [`@sanity/visual-editing-standalone`](../visual-editing-standalone/README.md)
> for a self-contained ESM build that does not install React peer dependencies.
> React applications should keep using this package to avoid bundling a second
> React runtime.

## Table of contents

- [Usage](#usage)
  - [Plain JS](#plain-js)
  - [Next.js](#nextjs)
    - [App Router](#app-router)
    - [Pages Router](#pages-router)
  - [React Router](#react-router)
  - [React.js](#reactjs)
- [Refresh API](#refresh-api)
  - [Plain JS](#plain-js-1)
    - [`source: 'manual'`](#source-manual)
    - [`source: 'mutation'`](#source-mutation)
  - [Next.js App Router](#nextjs-app-router)
    - [`source: 'manual'`](#source-manual-1)
    - [`source: 'mutation'`](#source-mutation-1)
  - [React Router](#react-router-1)
  - [SvelteKit](#sveltekit)
- [Stega and the clipboard](#stega-and-the-clipboard)
- [Detecting stega in unsafe places](#detecting-stega-in-unsafe-places)
- [Manually configuring "Edit in Sanity Studio" elements](#manually-configuring-edit-in-sanity-studio-elements)
  - [`data-sanity-edit-target`](#data-sanity-edit-target)
- [Change the z-index of overlay elements](#change-the-z-index-of-overlay-elements)

## Usage

### Plain JS

```ts
import {enableVisualEditing} from '@sanity/visual-editing'

// Enables visual editing overlays
enableVisualEditing()

// Integrate with a router that uses the History API
enableVisualEditing({
  history: {
    subscribe: (navigate) => {
      const handler = (event: PopStateEvent) => {
        navigate({
          type: 'push',
          url: `${location.pathname}${location.search}`,
        })
      }
      window.addEventListener('popstate', handler)
      return () => window.removeEventListener('popstate', handler)
    },
    update: (update) => {
      switch (update.type) {
        case 'push':
          return window.history.pushState(null, '', update.url)
        case 'pop':
          return window.history.back()
        case 'replace':
          return window.history.replaceState(null, '', update.url)
        default:
          throw new Error(`Unknown update type: ${update.type}`)
      }
    },
  },
})
```

### Next.js

If you're using Next v13 or later you can use first class components that integrate with the router. Depending on which router you're using you may use either, or both, of the following components.

#### App Router

For App Router you should use the `VisualEditing` component from `next-sanity`:

```sh
npm i next-sanity
```

In your root `layout.tsx`, assuming you're using [Draft Mode](https://nextjs.org/docs/app/building-your-application/configuring/draft-mode) to toggle when to enable Visual Editing, add the `VisualEditing` component:

```tsx
import {VisualEditing} from 'next-sanity/visual-editing'
import {draftMode} from 'next/headers'

export default function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html lang="en">
      <body>
        {children}
        {draftMode().isEnabled && (
          <VisualEditing
            zIndex={1000} // Optional
          />
        )}
      </body>
    </html>
  )
}
```

#### Pages Router

For Pages Router you should use the `VisualEditing` from `@sanity/visual-editing/next-pages-router`. Assuming you're using [Draft Mode](https://nextjs.org/docs/pages/building-your-application/configuring/draft-mode) or [Preview Mode](https://nextjs.org/docs/pages/building-your-application/configuring/preview-mode) to toggle when to enable Visual Editing, add the `VisualEditing` component to your `_app.tsx`:

```tsx
import {VisualEditing} from '@sanity/visual-editing/next-pages-router'
import type {AppProps} from 'next/app'
import {useRouter} from 'next/router'

export default function App({Component, pageProps}: AppProps) {
  const {isPreview} = useRouter()
  // A common alternative pattern to `isPreview` and `useRouter` is to pass down the draftMode/preview from getStaticProps/getServerSideProps/getInitialProps
  // const { draftMode } = pageProps
  return (
    <>
      <Component {...pageProps} />
      {isPreview && (
        <VisualEditing
          zIndex={1000} // Optional
        />
      )}
    </>
  )
}
```

### React Router

For React Router apps you should use `VisualEditing` from `@sanity/visual-editing/react-router` in your `app/root.tsx`:

```tsx
import {json} from '@react-router/node'
import {Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData} from '@react-router'
import {VisualEditing} from '@sanity/visual-editing/react-router'

export const loader = () => {
  return json({
    ENV: {
      SANITY_VISUAL_EDITING_ENABLED: process.env.SANITY_VISUAL_EDITING_ENABLED === 'true',
    },
  })
}

export default function App() {
  const {ENV} = useLoaderData<typeof loader>()

  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <main>
          <Outlet />
        </main>
        {ENV.SANITY_VISUAL_EDITING_ENABLED && (
          <VisualEditing
            zIndex={1000} // Optional
          />
        )}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  )
}
```

### React.js

On React apps that don't have a first-class framework integration may use the `enableVisualEditing` function directly in a `useEffect` hook.

```tsx
import { enableVisualEditing } from '@sanity/visual-editing'
import { useEffect } from 'react'

export default function VisualEditing() {
  useEffect(() => {
    const disable = enableVisualEditing({
      history: {} // recommended, integrate your router here so it works with the URL bar in Presentation
      zIndex: 1000, // optional
    })
    return () => disable()
  }, [])

  return null
}
```

## Refresh API

The refresh API is complimentary to the [Loaders][loaders] and [Preview Kit][preview-kit] APIs. It's used to refresh the page when the user has made changes to the document in the Studio and wants to see the changes reflected in the preview or when clicking on the "Refresh" button in the Presentation Tool UI.
For some frameworks, like Next.js App Router, Remix and soon SvelteKit, there's first-class implementations of the refresh API that does what you want out of the box, while still allowing you to customize it if you need to.

- When to use:
  - When you're using a framework that has a refresh API that provides a better experience than a full page reload.
    - [Next.js App Router][next-app-router]
    - [React Router][react-router]
    - [SvelteKit][sveltekit]
  - You have data fetching used in your app that it's either impractical or too costly to refactor to use [Loaders][loaders] or [Preview Kit][preview-kit].
  - You have other data fetching than Content Lake GROQ queries, for example GraphQL or REST APIs that you want to refresh.
- When not to use
  - If you're using a framework without a first-class refresh API.
  - You're already using [Loaders][loaders] or [Preview Kit][preview-kit] for all your data fetching.

The TypeScript signature for the API is:

```ts
interface VisualEditingOptions {
  refresh?: (payload: HistoryRefresh) => false | Promise<void>
}
type HistoryRefresh =
  | {
      source: 'manual'
      livePreviewEnabled: boolean
    }
  | {
      source: 'mutation'
      livePreviewEnabled: boolean
      document: {
        _id: string
        _type: string
        _rev: string
        slug?: {
          current?: string | null
        }
      }
    }
```

Returning `false` will trigger the default behavior, which is different depending on the `source` and `livePreviewEnabled` state.
Returning a Promise will report to Presentation Tool that a refresh is happening and will show a loading UI while the Promise is pending.

### Plain JS

#### `source: 'manual'`

It's fired when the user clicks on the "Refresh" button in the Presentation Tool.
The default behavior is effectively the same as `window.location.reload()`.

#### `source: 'mutation'`

The default behavior is to return `false`, as we can't make any assumptions of what the default behavior should be for your app if we don't know what framework you're using.

The payload will contain `livePreviewEnabled` and `document` properties.
`livePreviewEnabled` is true if either [Loaders][loaders] are detected to be setup in Live Mode, or if [Preview Kit][preview-kit] is enabled.
It allows you to chose a reload strategy based on wether the route you're on has live preview functionality or not, allowing you to incrementally adopt [Loaders][loaders] or [Preview Kit][preview-kit] without having to refactor all your data fetching at once.

The `document` part of the payload contains the `_id`, `_type`, `_rev` and `slug` properties of the document that was changed in the Studio. Depending on your app, you may want to use this information to decide if you want to refresh the page or not as well as which API to use.

### [Next.js App Router][next-app-router]

> [!NOTE]
> There's no default refresh API for Pages Router, as it doesn't have a first-class refresh API like App Router or Remix. But you can still use the `refresh` option to implement your own refresh logic by using the `refresh` prop on the `<VisualEditing />` component provided by `@sanity/visual-editing/next-pages-router`.

For App Router you should use the `VisualEditing` component from `next-sanity`:

```sh
npm i next-sanity
```

The implementation makes use of [Server Actions][server-actions], here's the default internal implementation (simplified):

```tsx
// app/layout.tsx
import {VisualEditing} from 'next-sanity/visual-editing'
import {revalidatePath, revalidateTag} from 'next/cache'
import {draftMode} from 'next/headers'

export default function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html lang="en">
      <head />
      <body>
        {children}
        {draftMode().isEnabled && (
          <VisualEditing
            refresh={async (payload) => {
              'use server'
              // Guard against a bad actor attempting to revalidate the page
              if (!draftMode().isEnabled) {
                return
              }
              if (payload.source === 'manual') {
                await revalidatePath('/', 'layout')
              }
              // Only revalidate on mutations if the route doesn't have loaders or preview-kit
              if (payload.source === 'mutation' && !payload.livePreviewEnabled) {
                await revalidatePath('/', 'layout')
              }
            }}
          />
        )}
      </body>
    </html>
  )
}
```

#### `source: 'manual'`

If your application is using `revalidateTag` then it's common to add the tag `all` to all data fetches. If you follow this pattern then you can reduce the impact of a manual refresh by using it here as well:

```tsx
<VisualEditing
  refresh={async (payload) => {
    'use server'
    // Guard against a bad actor attempting to revalidate the page
    if (!draftMode().isEnabled) {
      return
    }
    if (payload.source === 'manual') {
      await revalidateTag('all', {expire: 0})
    }
  }}
/>
```

#### `source: 'mutation'`

If you're using `revalidateTag`, [and the GROQ webhook pattern][https://github.com/sanity-io/next-sanity#tag-based-revalidation-webhook], then you can reuse it here on route level as well:

```tsx
<VisualEditing
  refresh={async (payload) => {
    'use server'
    // Guard against a bad actor attempting to revalidate the page
    if (!draftMode().isEnabled) {
      return
    }
    if (payload.source === 'manual') {
      await revalidateTag('all', {expire: 0})
    }
    if (payload.source === 'mutation') {
      // Call `revalidateTag` in the same way as ./app/api/revalidate/route.ts
      await revalidateTag(payload.document._type, {expire: 0})
    }
  }}
/>
```

You can use `payload.livePreviewEnabled` and `payload.document` to better target scenarios where you want to `revalidateTag` or when it may already be handled by a `useQuery` hook from `@sanity/react-loader` already, or a `useLiveQuery` hook from `next-sanity/preview` or `@sanity/preview-kit`.

### [React Router][react-router]

For Remix apps the implementation is much like the one for [Next.js App Router][next-app-router] when it comes to what happens depending on the `source` and `livePreviewEnabled` properties of the payload.

Remix doesn't have [Server Actions][server-actions] yet, under the hood the [`useRevalidator`](https://remix.run/docs/en/main/hooks/use-revalidator) hook is used. Here's the default internal implementation (simplified):

```tsx
// app/root.tsx
import {useRevalidator} from 'react-router'
import {VisualEditing} from '@sanity/visual-editing/react-router'

export default function App() {
  const {ENV} = useLoaderData<typeof loader>()
  const revalidator = useRevalidator()

  return (
    <html lang="en">
      <body>
        <Outlet />
        {ENV.SANITY_VISUAL_EDITING_ENABLED && (
          <VisualEditing
            refresh={(payload) => {
              if (payload.source === 'manual') {
                revalidator.revalidate()
              }
              if (payload.source === 'mutation' && !payload.livePreviewEnabled) {
                revalidator.revalidate()
              }
            }}
          />
        )}
      </body>
    </html>
  )
}
```

If you only want to configure **when** revalidation is called, and not the actual implementation, then you can call the `refreshDefault` function so you don't have to handle `useRevalidator` and its loading states yourself.

```tsx
<VisualEditing
  refresh={(payload, refreshDefault) => {
    if (payload.source === 'manual') {
      return refreshDefault()
    }
    // Always revalidate on mutations for document types that are used for MetaFunctions that render in <head />
    if (payload.source === 'mutation' && payload.document._type === 'settings') {
      return refreshDefault()
    }
  }}
/>
```

### [SvelteKit][sveltekit]

A first class implementation for SvelteKit is coming soon.

## Stega and the clipboard

Visual editing locates editable content by looking for [stega-encoded metadata](https://www.sanity.io/docs/stega) — sequences of invisible characters appended to strings. Those characters would normally tag along when content is copied from a preview and pasted into other tools, showing up as unexpected garbage in plain text fields, URLs, spreadsheets and the like.

To prevent this, while Visual Editing is enabled, `copy` events are intercepted and stega metadata is automatically stripped from the clipboard (both the `text/plain` and `text/html` flavors). Copies that don't contain stega are left completely untouched, and `copy` handlers of your own that call `event.preventDefault()` take precedence.

If you want to keep the stega metadata in copied content, opt out with `keepStegaOnCopy`:

```tsx
<VisualEditing keepStegaOnCopy />
```

```ts
enableVisualEditing({keepStegaOnCopy: true})
```

## Detecting stega in unsafe places

Stega metadata is designed to live in rendered text (and a few attributes such as `img[alt]`, `time[datetime]` and `aria-label` on `svg`). If it ends up anywhere else — usually because a content value was rendered somewhere it shouldn't be without cleaning it first — it will always cause bugs or bloat. Provide the `onSuspiciousStega` callback to detect and report these cases:

```tsx
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports) {
      console.warn(`Stega found in ${report.kind}`, report)
    }
  }}
/>
```

Providing the callback opts in to the detection logic — when it isn't provided, no scanning runs. The scan happens during browser idle time: an initial audit of the document, then incremental checks of DOM changes. Reports are deduped and batched, and each report includes the offending `element`, the raw `value`, the `cleaned` value it should have been, and — when the payload can be decoded — the `sanity` edit info pointing to the document and field that produced the value.

Reported placements (`report.kind`):

| `kind`       | What it means                                                                                                                                                                                                                                                                      |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `attribute`  | Stega in an attribute such as `class` (selectors no longer match), `id` (broken anchors and `getElementById`), `href`/`src` and other URL attributes (the invisible characters end up percent-encoded in requests), `style`, `name`, `value` or `data-*` (broken equality checks). |
| `head`       | Stega anywhere inside `<head>`, e.g. rendering `data.title` in `<title>` or `meta[content]`. It's never visible, so it's pure bloat that also corrupts SEO and social metadata.                                                                                                    |
| `script`     | Stega inside a `<script>` element, e.g. JSON-LD structured data or embedded state.                                                                                                                                                                                                 |
| `style`      | Stega inside a `<style>` element, breaking selectors or values.                                                                                                                                                                                                                    |
| `form-value` | Stega in a form field value (e.g. `<textarea>` content), where it would be submitted along with user input.                                                                                                                                                                        |
| `url`        | Stega in the page URL itself, meaning the page was reached through a link that had stega encoded into it.                                                                                                                                                                          |

The fix is usually to clean the value before rendering it with [`stegaClean` from `@sanity/client/stega`](https://www.sanity.io/docs/stega), or to exclude the field from stega encoding entirely using your client's `stega.filter` configuration.

## Manually configuring "Edit in Sanity Studio" elements

### `data-sanity-edit-target`

You can choose which element to render the "Edit in Sanity Studio" buttons on by adding a `data-sanity-edit-target` attribute to the element you want to be clickable. This allows you to move the edit container to a parent wrapper element.

In this example, by default the edit button would be placed on the `<h1>` tag

```html
<section>
  <h1>{dynamicTitle}</h1>
  <div>Hardcoded Tagline</div>
</section>
```

But by adding the `data-sanity-edit-target` attribute to the `<section>` tag, the edit button will be placed on it instead.

```html
<section data-sanity-edit-target>
  <h1>{dynamicTitle}</h1>
  <div>Hardcoded Tagline</div>
</section>
```

Manually setting the edit target will use the first element it finds with encoded metadata and remove clickable buttons from all other child elements.

## Change the z-index of overlay elements

```ts
enableVisualEditing({
  zIndex: 1000,
})
```

[gzip-badge]: https://img.shields.io/bundlephobia/minzip/@sanity/visual-editing?label=gzip%20size&style=flat-square
[size-badge]: https://img.shields.io/bundlephobia/min/@sanity/visual-editing?label=size&style=flat-square
[bundlephobia]: https://bundlephobia.com/package/@sanity/visual-editing
[loaders]: https://www.sanity.io/docs/loaders-and-overlays
[preview-kit]: https://www.sanity.io/plugins/preview-kit
[next-app-router]: https://nextjs.org/docs/app
[react-router]: https://reactrouter.com/
[sveltekit]: https://kit.svelte.dev/
[server-actions]: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

---
_Source: https://npm.io/package/@sanity/visual-editing · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
