DatoCMS Visual Editing
Click-to-edit overlays for content rendered from DatoCMS – without the Vercel toolbar.
This library decodes the steganographic payload that ships with DatoCMS preview responses,
stamps DOM attributes on the real elements in your page, and wires up pointer/keyboard
interactions that deep-link straight to the right record + field inside the editor.
- DOM is the source of truth. There is no in-memory cache, no persisted map. Attributes carry all metadata.
- Zero-width markers are scrubbed. As soon as we stamp the attributes, the invisible characters disappear.
- Overlays resolve from attributes only. Pointer/focus events look up the nearest
data-datocms-edit-url
– nothing else.
npm install datocms-visual-editing
Quick start
Fetch preview content with visual editing headers.
import { withContentLinkHeaders } from 'datocms-visual-editing'; const fetchDato = withContentLinkHeaders(fetch); const response = await fetchDato('https://graphql.datocms.com/', { method: 'POST', headers: { Authorization: `Bearer ${process.env.DATO_PREVIEW_API_TOKEN}`, 'X-Base-Editing-Url': 'https://acme.admin.datocms.com' }, body: JSON.stringify({ query }) });
Instantiate
enableDatoVisualEditing
once your preview page renders.import { enableDatoVisualEditing } from 'datocms-visual-editing'; const visualEditing = enableDatoVisualEditing({ baseEditingUrl: 'https://acme.admin.datocms.com', environment: 'main' // optional – attaches to every stamped element }); // Optional toggle button: document .getElementById('toggle-visual-editing') ?.addEventListener('click', () => visualEditing.toggle()); // Call `visualEditing.dispose()` if you unmount the page (SPA route change, etc.).
That’s it. On activation we scan the DOM, decode stega payloads, stamp attributes, scrub markers,
and start a single MutationObserver
to keep new nodes in sync. Overlays are live immediately.
Streaming & rehydration
When streaming preview responses (React Server Components, Remix responses, or SSE “Listen” updates), reuse the exact DOM nodes that shipped from the server. The _editingUrl
metadata travels on those elements; replacing them breaks overlays. Mutate text/attributes in place and call controller.refresh(root?)
after the new markup lands (or use the useDatoVisualEditingListen
hook below). The controller batches rescans, fires lifecycle events, and—in development—warns once if the initial enable detects zero editables (a common signal that the DOM was replaced). Copy-pasteable setups live under examples/nextjs-listen-app-router
, examples/nextjs-listen-pages-router
, and examples/remix-listen
.
Attribute contract
Every editable target receives the following attributes (if the data exists):
Attribute | Purpose |
---|---|
data-datocms-edit-url |
Fully-qualified deep link to the record and field in the DatoCMS editor. |
data-datocms-editable |
Convenience flag stamped on every editable target (explicit or generated). |
data-datocms-item-id |
Record ID (diagnostics) |
data-datocms-item-type-id |
Model API key/ID (diagnostics) |
data-datocms-environment |
Environment slug when provided |
data-datocms-locale |
Locale code extracted from stega payload |
data-datocms-generated="stega" |
Guard flag – we only remove the attributes that we stamped |
You can still author attributes manually (for example in static markup). We never overwrite developer-authored
values because they lack the guard flag. During dispose we only clean elements that carry
data-datocms-generated="stega"
.
Layout helpers
Add data-datocms-edit-target
to any wrapper that should receive the attributes instead of the inner element.
We honour it automatically for both text nodes and images. Zero-sized <img>
elements are upgraded to their
nearest wrapper so the overlay remains clickable.
Runtime behaviour
- Initial scan – walks text nodes +
<img alt>
values inside the providedroot
(defaults todocument
), decodes the stega payload, stamps attributes on the nearest element, and replaces the text/alt with the clean payload. - MutationObserver – watches character data, child list changes, and
alt
mutations to rerun the scan when new content appears. Work is batched viaqueueMicrotask
so bursts of mutations coalesce. - Overlay controller – listens for pointer hover, clicks, focus, and keyboard activation. The nearest ancestor
with
data-datocms-edit-url
wins. We compute the highlight box directly from the DOM and open deep links in a new tab (window.open(url, '_blank', 'noopener,noreferrer')
). - Dispose – disconnects the observer, tears down listeners, and removes only the generated attributes.
API Reference
enableDatoVisualEditing(options): VisualEditingController
type EnableDatoVisualEditingOptions = {
baseEditingUrl: string; // required
environment?: string; // optional environment slug for diagnostics + deep links
root?: ParentNode; // restrict scanning/observation to a subtree (default: document)
debug?: boolean; // expose debug attributes for in-browser inspection (default: false)
autoEnable?: boolean; // set to false when you want manual enable/disable control (default: true)
devPanel?: boolean | { // show a floating dev counter panel in dev builds
position?: 'br' | 'bl' | 'tr' | 'tl';
};
onReady?: (summary: MarkSummary) => void;
onMarked?: (summary: MarkSummary) => void;
onStateChange?: (state: { enabled: boolean; disposed: boolean }) => void;
onWarning?: (warning: { code: string; message: string }) => void;
};
Returns a controller with the following methods:
enable()
/disable()
/toggle()
– control overlays on demand.isEnabled()
/isDisposed()
– expose state for UI bindings.dispose()
– permanently tear everything down and scrub generated attributes.refresh(root?)
– queue a stega rescan for the whole root or a specific subtree (handy after streaming updates).
SPA note: call
dispose()
on route changes if you mount/unmount the preview surface manually. After disposal the controller becomes inert.
Lifecycle callbacks receive a MarkSummary
object (editableTotal
, generatedStamped
, generatedUpdated
, explicitTotal
, and the processed scope
). The same payload is dispatched as DOM CustomEvent
s:
datocms:visual-editing:ready
(exported asEVENT_READY
)datocms:visual-editing:marked
(exported asEVENT_MARKED
)datocms:visual-editing:state
(exported asEVENT_STATE
)datocms:visual-editing:warn
(exported asEVENT_WARN
)
Warnings fire only in development (for example when enable() finds zero editables) and surface through both the onWarning
callback and the DOM event.
Debug inspection toggle
Pass debug: true
to stamp additional diagnostics on every editable element and any explicit data-datocms-edit-url
you authored yourself. The library adds:
data-datocms-debug="on"
data-datocms-debug-reason
("stega"
when derived from steganographic payloads,"explicit"
for attributes you authored)data-datocms-debug-url
(the resolved deep link)data-datocms-debug-info
(JSON payload with the decoded metadata)
These attributes make it easy to inspect the resolved editing info directly in DevTools. They are removed automatically when you call dispose()
on the controller returned by enableDatoVisualEditing
.
Dev panel & state inspectors
- Set
devPanel: true
(or{ position: 'tr' | 'tl' | 'br' | 'bl' }
) to spawn a lightweight overlay with live counters while developing. - Use
checkStegaState(root?)
to get programmatic insight into editable totals, generated vs. explicit counts, info-only attributes, and leftover encoded markers. - Drop the
DatoVisualEditingDevPanel
React component anywhere inside your preview shell to render the same diagnostics using JSX.
withContentLinkHeaders(fetchLike)
Wraps fetch
(or a compatible function) so every request sends the headers required by DatoCMS to embed visual editing metadata.
autoCleanStegaWithin(root, options)
/ enableDatoAutoClean(selector, options)
Utility helpers to scrub stega markers from a subtree on demand. They complement the automatic cleanup performed by
enableDatoVisualEditing
and remain useful if you want to clean additional regions outside the visual editing scope.
React helpers
useDatoAutoClean(ref, options)
– React hook that runsautoCleanStegaWithin
.DatoAutoClean
– minimal component that stampsdata-datocms-auto-clean
and wires the hook for you.useDatoVisualEditingListen(subscribe, options)
– keeps overlays in sync with Dato “Listen” subscriptions (streaming / SSE preview updates).DatoVisualEditingDevPanel
– React counterpart of the dev panel for JSX-based preview shells.
Low-level utilities
decodeStega(string)
/stripStega(string)
– stega helpers re-exported for convenience.buildEditTagAttributes(info, format)
– build explicit attributes if you want to hand-stamp elements server-side.getDatoEditInfo(element)
– read explicit attributes or JSON payloads from markup you crafted yourself.
All attribute names are exported from datocms-visual-editing/constants
as ATTR_*
constants.
Working with custom roots
Pass root
to scope scanning and observation to a particular subtree (for example, a ShadowRoot or a specific CMS slot):
const shadowRoot = document.querySelector('#preview-host')?.shadowRoot;
if (shadowRoot) {
const visualEditing = enableDatoVisualEditing({
baseEditingUrl: 'https://acme.admin.datocms.com',
root: shadowRoot,
autoEnable: false
});
visualEditing.enable();
}
We only touch nodes inside that root, and calling dispose()
removes attributes within the same boundary.
Integration testing
The Vitest integration suite (tests/dato.integration.test.ts
) talks to a live DatoCMS project.
Create a .env.visual-editing
file (use .env.example
as a template) and provide:
DATOCMS_VISUAL_EDITING_TOKEN
– Preview Content API token.DATOCMS_VISUAL_EDITING_BASE_URL
– project admin URL, e.g.https://yourproject.admin.datocms.com
.- Optional:
DATOCMS_VISUAL_EDITING_GRAPHQL_URL
when using a custom preview endpoint.
Vitest automatically loads .env.visual-editing
, .env.test
, .env.local
, and .env
before running, so once the file exists you can run pnpm run test
without exporting variables manually.
The test/inspectStega.mjs
helper reuses the same variables, so no extra setup is required.
AutoClean collaboration
enableDatoVisualEditing
already scrubs zero-width markers after stamping attributes. If you are also using the
stand-alone AutoClean helpers (for example, to clean rich text rendered outside the visual editing surface), the
two approaches coexist happily: attributes always come from the DOM, so there is no cache to fall out of sync.
Troubleshooting
- No overlay shows up: confirm the rendered element has
data-datocms-edit-url
. If not, inspect the raw text/alt – you may be missing visual editing headers in your fetch. - Wrong element is highlighted: add
data-datocms-edit-target
to the wrapper you want to enlarge. - Console warning about multiple stega payloads: the library warns when two encoded strings resolve to the same DOM element. Split the content into dedicated wrappers (e.g. each with
data-datocms-edit-target
) so every edit URL has its own target. - Links open in the same tab: browser pop-up rules can block
window.open
. Allow pop-ups for your preview domain or change the behaviour by wrappingenableDatoVisualEditing
and callingwindow.location.assign
yourself.
License
MIT © DatoCMS