#Click to Edit - Advanced API
This page covers advanced Preview SDK APIs beyond the standard Click to Edit setup. Use it when you need custom save handling, connection status, dynamic DOM updates, live updates for scalar lists, or lower-level control than PreviewWrapper provides.
For framework walkthroughs, see:
#Additional configuration
These options extend the configuration properties on the parent setup page.
| Property | Required | Description |
|---|---|---|
overlayEnabled | Optional | Set to false to disable hover overlays and Edit buttons while keeping the Studio connection active. Defaults to true. Available on both HygraphPreview and the core Preview constructor. |
onFieldUpdate | Optional | Handle live field updates yourself. Receives the update (entryId, fieldApiId, fieldType, newValue, and isList or componentChain wherever applicable). Setting it replaces the built-in updater for every field, not just the ones you handle: nothing is written to the DOM unless you write it. Leave it unset to keep the built-in behavior and listen for preview:field-updated instead. |
<HygraphPreviewendpoint={process.env.NEXT_PUBLIC_HYGRAPH_ENDPOINT!}studioUrl={process.env.NEXT_PUBLIC_HYGRAPH_STUDIO_URL}overlayEnabled={false}onSave={() => router.refresh()}>{children}</HygraphPreview>
#React hooks
Import hooks from @hygraph/preview-sdk/react. They must run inside a tree wrapped by HygraphPreview.
#usePreview
Returns the Preview instance and connection state.
import { usePreview } from '@hygraph/preview-sdk/react';function PreviewStatus() {const { preview, isReady, isConnected } = usePreview();return (<p>Ready: {String(isReady)} · Connected: {String(isConnected)} · Mode:{' '}{preview?.getMode() ?? 'n/a'}</p>);}
#usePreviewSave
Subscribes to save events. Use this instead of (or in addition to) the onSave prop when save handling lives in a child component.
import { usePreviewSave } from '@hygraph/preview-sdk/react';import { useRouter } from 'next/navigation';function SaveListener() {const router = useRouter();usePreviewSave((entryId) => {console.log('Saved entry:', entryId);router.refresh();});return null;}
#usePreviewEvent
Subscribes to any DOM event the SDK dispatches on document.
import { usePreviewEvent } from '@hygraph/preview-sdk/react';function FieldClickListener() {usePreviewEvent('preview:field-click', (event) => {console.log('Field clicked:', event.detail);});return null;}
#Other React hooks
| Hook | Description |
|---|---|
usePreviewRefresh | Returns a framework-aware refresh() helper. Falls back to window.location.reload() when no framework integration is detected. |
usePreviewRemix | Subscribes to save events and revalidates with the Remix revalidator when available. |
usePreviewFieldUpdates | Callbacks for preview:field-updated and preview:update-failed when sync.fieldUpdate is enabled. |
usePreviewConnection | Returns { isConnected, isReady, mode }. |
usePreviewActions | Returns { refresh, destroy, getVersion, getMode } for manual control. |
usePreviewDebug | Returns registry stats and framework detection for development. |
#HygraphPreviewNextjs
Optional Next.js helper that wires refresh for you. Most apps use HygraphPreview with next/dynamic and onSave={() => router.refresh()} instead, as shown in the App Router guide.
import { HygraphPreviewNextjs } from '@hygraph/preview-sdk/react';import { useRouter } from 'next/navigation';export function PreviewWrapper({ children }: { children: React.ReactNode }) {const router = useRouter();return (<HygraphPreviewNextjsendpoint={process.env.NEXT_PUBLIC_HYGRAPH_ENDPOINT!}studioUrl={process.env.NEXT_PUBLIC_HYGRAPH_STUDIO_URL}refresh={router.refresh}>{children}</HygraphPreviewNextjs>);}
#Core Preview methods
When you initialize the SDK with new Preview() (vanilla JavaScript, Vue, Nuxt), use these methods on the instance.
| Method | Description |
|---|---|
subscribe('save', { callback }) | Listens for Studio save events. Returns an unsubscribe function. |
refresh() | Re-scans the DOM for data-hygraph-* attributes. Call after you inject or replace HTML without a full page reload. |
getMode() | Returns 'iframe' or 'standalone'. |
isConnected() | Returns whether the SDK is connected to Studio. |
getVersion() | Returns the SDK version string. |
configureOverlay(config) | Updates overlay styles at runtime. |
destroy() | Tears down listeners, overlays, and the Studio connection. Call on page unload in SPAs. |
getFrameworkIntegration() | Returns the SDK's framework integration helper used for framework-aware refresh. |
getFieldRegistryStats() | Returns counts and diagnostics for registered preview fields. Useful when debugging missing overlays. |
getFieldRegistryKeys() | Returns the registry keys for currently tracked fields. |
import { Preview } from '@hygraph/preview-sdk/core';const preview = new Preview({endpoint: process.env.HYGRAPH_ENDPOINT,studioUrl: process.env.HYGRAPH_STUDIO_URL,debug: true,});const unsubscribe = preview.subscribe('save', {callback: (entryId) => {console.log('Saved:', entryId);window.location.reload();},});window.addEventListener('beforeunload', () => {unsubscribe();preview.destroy();});
#Dynamic content without a full reload
If your app updates the DOM without navigating away, call refresh() after new marked-up HTML is in the page:
container.innerHTML = `<h1data-hygraph-entry-id="${entry.id}"data-hygraph-field-api-id="title">${entry.title}</h1>`;preview.refresh();
#Live preview for component arrays
When sync.fieldUpdate is enabled, the SDK can update component arrays (modular content) in the preview without a full page refresh:
- Reordering (drag-and-drop) — existing DOM elements move in place
- Deletion — removed components disappear immediately
- Addition — new unsaved components are skipped until you save and refresh the page
#Set up a component array container
The SDK needs:
- A container element with
data-hygraph-entry-idanddata-hygraph-field-api-idpointing to the component array field - Direct children with
data-hygraph-component-chainso the SDK can identify each component instance
import {createPreviewAttributes,createComponentChainLink,} from '@hygraph/preview-sdk/core';function ArticlePage({ article }) {return (<main><h1 {...createPreviewAttributes({ entryId: article.id, fieldApiId: 'title' })}>{article.title}</h1>{/* Container — the SDK targets this for reordering */}<div{...createPreviewAttributes({entryId: article.id,fieldApiId: 'content', // Must match the component array field API ID})}>{article.content.map((block) => {const componentChain = [createComponentChainLink('content', block.id)];return (<divkey={block.id}data-hygraph-component-chain={JSON.stringify(componentChain)}><ContentBlockblock={block}articleId={article.id}componentChain={componentChain}/></div>);})}</div></main>);}
#How it works
When Studio detects a structural change (reorder, add, or delete) in a component array, it sends a COMPONENT_ARRAY field update. The SDK then:
- Finds the container via
data-hygraph-entry-idanddata-hygraph-field-api-id - Reads
data-hygraph-component-chainfrom each direct child to map component IDs to DOM elements - Reorders existing DOM elements to match the new array order
- Removes elements for deletions
- Skips new unsaved components until save and refresh
For Rich Text fields rendered as HTML inside components, add data-hygraph-rich-text-format="html" so live field updates use the correct format.
#Troubleshooting component arrays
- Confirm the container has
data-hygraph-entry-idanddata-hygraph-field-api-idmatching the component array field. - Confirm each direct child has
data-hygraph-component-chainwith the component instance ID. - Enable
debug={true}(ordebug: true) to see[ContentUpdater] COMPONENT_ARRAYlogs. - New unsaved components appear only after saving and refreshing. Reordering and deletion of existing components work immediately when
sync.fieldUpdateis enabled.
#Live preview for scalar lists
When sync.fieldUpdate is enabled, a field marked as a list in the schema, such as String, ID, Enumeration, Int, Float, Boolean, Date, or DateTime, updates in the preview as an editor types. This needs @hygraph/preview-sdk 1.1.0 or later: Studio withholds list updates from earlier versions, which have no way to render them.
Studio sends the whole array on every change, so each update carries the full list, not a delta. You can bind that list to a single element, tag one element per item, or render it yourself.
#Bind the whole list to one element
An element tagged with the entry ID and the field API ID receives every item, joined with , :
<p {...createPreviewAttributes({ entryId: recipe.id, fieldApiId: 'tags' })}>{recipe.tags.join(', ')}</p>
The SDK only patches the elements your page rendered. So keep these two things in mind:
- Join your own render with
,. Any other separator is replaced by,on the first live update, and the preview stops matching the page a reload would produce. - Items are written as plain values, not formatted ones. A Date list renders the stored values, such as
2026-01-15, where a single Date field is formatted for the browser's locale. An item that isnullrenders as an empty string, so[10, null, 30]reads10, , 30. Use per-item bindings or your own rendering if the formatting matters.
An <input> or <textarea> receives the joined string as its value.
The SDK cannot rebuild markup it did not render, so it refuses to write a list into an element that has child elements, such as a <ul> of <li> items. It dispatches preview:update-failed instead, and your markup stays as it is.
#Bind each item to its own element
Pass listIndex as a non-negative integer to tag one element per item. Each element then receives the item at its index:
<ul>{recipe.tags.map((tag, index) => (<likey={`${tag}-${index}`}{...createPreviewAttributes({entryId: recipe.id,fieldApiId: 'tags',listIndex: index,})}>{tag}</li>))}</ul>
The SDK only ever patches the elements your page rendered. It does not add or remove any elements. So the item count does not follow the editor:
- An item removed in Studio empties the element bound to that index rather than removing it, which leaves a blank row (an empty bullet, for instance) until the next reload.
- An item added in Studio appears only if an element is already tagged with that index. Tagging one spare index renders nothing until an item exists there.
#Render the list yourself
For a list whose length changes as editors work, or whose items need your own formatting, take the array and render it. Either listen for the event:
document.addEventListener('preview:field-updated', (event) => {if (event.detail.fieldApiId === 'tags' && event.detail.isList) {renderTags(event.detail.newValue); // the full array}});
Or pass onFieldUpdate, which hands you every update before the DOM is touched. Note that it replaces the built-in updater for every field, so a page that sets it is responsible for rendering all of its live updates, not just its lists:
<HygraphPreviewendpoint={process.env.NEXT_PUBLIC_HYGRAPH_ENDPOINT!}sync={{ fieldUpdate: true }}onFieldUpdate={(update) => {// A scalar list arrives as the full array, every other field as a single value.// Render from state and the item count follows the editor.setFields((current) => ({ ...current, [update.fieldApiId]: update.newValue }));}}>{children}</HygraphPreview>
<ul>{(fields.tags ?? recipe.tags).map((tag, index) => (<li key={`${tag}-${index}`}>{tag}</li>))}</ul>
#Fields that are lists but do not update
isList covers scalar lists only. A Rich Text list and a JSON field holding an array are skipped deliberately: their values are arrays of rich text ASTs or of arbitrary JSON, which Studio cannot tell apart from a String list and the SDK could not render either way. Those fields change in the preview after a save and a refresh. Component arrays have their own path; see live preview for component arrays.
#Localized lists
This SDK carries no locale. The data-hygraph-* attributes identify an entry and a field, but not a locale. A binding for a localized list is therefore patched by an edit in any locale, including one the preview is not showing. If your page renders a single locale, re-fetch on save rather than trusting a live update that may have come from another one.
#Troubleshooting scalar lists
- Confirm the installed
@hygraph/preview-sdkis 1.1.0 or later. Earlier versions do not receive list updates. - Confirm
sync={{ fieldUpdate: true }}is set on yourPreviewWrappercomponent. - Confirm the element you tagged has no child elements, or that each item carries
data-hygraph-list-index. - Enable
debugand watch forpreview:update-failed. It names the field and, for a container the SDK refused, says which element it would have destroyed. - Nothing at all in the log for a list field? Check that the field really is a scalar list in the schema. A Rich Text or JSON field sends no list update.
#DOM events
The SDK dispatches these events on document. Listen with addEventListener or usePreviewEvent.
| Event | event.detail | When it fires |
|---|---|---|
preview:ready | { preview } | SDK finished initializing. |
preview:connected | { studioOrigin } | Connected to Studio in iframe mode. |
preview:disconnected | {} | Connection to Studio ended. |
preview:content-saved | { entryId, timestamp } | Studio reported a save. |
preview:field-click | { entryId, fieldApiId?, componentChain?, mode? } | Editor clicked an Edit overlay. |
preview:field-focus | { entryId, fieldApiId } | Studio requested field focus sync. |
preview:field-updated | { entryId, fieldApiId, newValue, fieldType?, componentChain?, isList?, transformedValue? } | A live field update reached the DOM (sync.fieldUpdate: true). isList is true when newValue is a list; transformedValue carries the flattened array for a component array. With onFieldUpdate set, this fires for every update the handler receives. |
preview:update-failed | { entryId, fieldApiId, error } | A live update could not be applied. The element does not carry the field, or the value cannot be rendered into the element that does. Only the built-in updater reports this; a page using onFieldUpdate never sees it. |
document.addEventListener('preview:ready', () => {console.log('Preview SDK ready');});document.addEventListener('preview:content-saved', (event) => {console.log('Content saved:', event.detail.entryId);});document.addEventListener('preview:field-click', (event) => {console.log('Field clicked:', event.detail);});
#Attribute helpers
These helpers from @hygraph/preview-sdk/core are used throughout the framework guides. The parent attribute reference covers the HTML attributes they produce.
| Helper | Description |
|---|---|
createPreviewAttributes({ entryId, fieldApiId?, componentChain?, listIndex? }) | Returns an object of data-hygraph-* attributes for JSX or v-bind. Pass listIndex for one item of a scalar list; see live preview for scalar lists. |
createComponentChainLink(fieldApiId, instanceId) | Builds one { fieldApiId, instanceId } link for nested components. |
withFieldPath(attributes, fieldPath) | Adds data-hygraph-field-path (for example, ingredients.0.quantity) for debugging nested fields. It does not change Studio focus behavior. |
import {createPreviewAttributes,createComponentChainLink,withFieldPath,} from '@hygraph/preview-sdk/core';const chain = [createComponentChainLink('ingredients', ingredient.id)];const quantityAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'quantity',componentChain: chain,}),`ingredients.${index}.quantity`);
Attributes identify an entry and a field, not a locale, so the same tagged element serves every locale of a localized field. Click to Edit opens the field in the default locale; see the known limitation, and localized lists for what that means for live updates.
#What's next
- Click to Edit setup: Install steps, attribute reference, Studio widget, and troubleshooting.
- Click to Edit - Vanilla JavaScript: UMD setup and server-rendered attribute examples.
- Preview SDK on GitHub: Source, changelog, and runnable examples.