#Click to Edit - Next.js App Router
The Click to Edit feature lets editors jump directly from the content preview to the exact field they want to edit in Hygraph Studio. Once configured using the Hygraph Preview SDK, you can:
- Hover over a tagged element in your preview to display an Edit button.
- Click the Edit button to open the corresponding field entry in Studio.
- Save your changes to automatically refresh your preview.
The following example code demonstrates how to implement this feature using the Next.js App Router. Additional examples for supported frameworks are available at:
#Steps
- Install the Preview SDK.
- Create a PreviewWrapper component to enable the preview functionality.
- Set environment variables.
- Add data attributes to your content elements.
- Set up the Preview widget in Studio
- Use the Click to Edit feature from content previews
#Install the Preview SDK
The Preview SDK is the bridge between Hygraph and your frontend. The Preview SDK transforms your preview into an interactive experience where content updates appear instantly as you save your changes. You can click any content element in the preview to jump directly to its field in the editor. To install the Preview SDK, run the following command:
npm install @hygraph/preview-sdk
#Create the PreviewWrapper component
-
Create a
PreviewWrapper.tsxfile in thecomponentsfolder in your project directory. In thiscomponents/PreviewWrapper.tsxfile, you need to create a wrapper component that enables the preview functionality.// components/PreviewWrapper.tsx'use client';import { useRouter } from 'next/navigation';import dynamic from 'next/dynamic';const HygraphPreview = dynamic(() => import('@hygraph/preview-sdk/react').then(mod => ({ default: mod.HygraphPreview })),{ ssr: false });export function PreviewWrapper({ children }) {const router = useRouter();return (<HygraphPreviewendpoint="https://your-region.cdn.hygraph.com/content/your-project-id/master"studioUrl="https://app.hygraph.com" // Replace by your Hygraph project's custom domaindebug={true} // Optional: Enable console loggingmode="iframe" // Optional: 'iframe' | 'standalone' | 'auto'onSave={(entryId) => { // Optional: Custom save handlerconsole.log('Content saved:', entryId);router.refresh();}}overlay={{ // Optional: Customize overlay stylingstyle: {borderColor: '#3b82f6',borderWidth: '2px',},button: {backgroundColor: '#3b82f6',color: 'white',},}}sync={{fieldFocus: true, // Optional: Enable field focus sync from StudiofieldUpdate: false, // Optional: Apply live field updates to Preview}}>{children}</HygraphPreview>);} -
In the
app/layout.tsxfile, import thePreviewWrappercomponent.// app/layout.tsximport { PreviewWrapper } from '@/components/PreviewWrapper';export default function RootLayout({ children }) {return (<html><body><PreviewWrapper>{children}</PreviewWrapper></body></html>);}
Configuration properties
| Property | Required / Optional | Description |
|---|---|---|
endpoint | Required | Hygraph Content API endpoint. To learn how to retrieve the Content API endpoint, see our docs. |
studioUrl | Required | Your project's custom domain (e.g., https://studio-eu-central-1-shared-euc1-02.hygraph.com). Must not end with a trailing / or Click to Edit will fail in standalone mode. |
debug | Optional | Enables verbose console logs to diagnose attribute issues. |
mode | Optional | Forces a specific mode. Options: 'iframe' | 'standalone' | 'auto'. Auto-detection works for most cases. |
onSave | Optional | Runs after Hygraph reports a save and receives the entry ID for targeted revalidation. |
overlay | Optional | Customize overlay border and button appearance. |
sync.fieldFocus | Optional | Focuses the field in Studio when clicking an overlay. |
sync.fieldUpdate | Optional | Updates the preview immediately when field updates happen in Studio. Defaults to false. |
allowedOrigins | Optional | Extends the list of domains that can host your preview iframe. Example: For shared preview environments (QA, staging), add the base URL here. |
#Set environment variables
Retrieve the following values, and set them as environment variables in the .env.local file in your app's root directory.
- Content API endpoint - This is the value of
NEXT_PUBLIC_HYGRAPH_ENDPOINT. Copy your Content API endpoint from your Hygraph project settings under Project Settings > Access > Endpoints > High Performance Content API.For more information, see our dedicated docs on the Content API. - Hygraph Studio base URL - This is the value of
NEXT_PUBLIC_HYGRAPH_STUDIO_URL. Copy the base Studio URL from your browser's address bar. Example:https://studio-eu-central-1-shared-euc1-02.hygraph.com. Ensure that the URL does not end with a/. Otherwise, Click to Edit will not work in standalone mode, outside of Hygraph Studio. - Permanent Auth Token - This is the value of
HYGRAPH_TOKEN. You can create a Permanent Auth Token from your Hygraph project settings under Project Settings > Access > Permanent Auth Tokens. Check that the default content stage is DRAFT. For more information, see our dedicated docs on Permanent Auth Tokens.- This is needed only if your Hygraph project requires authentication for Content API requests.
# .env.localNEXT_PUBLIC_HYGRAPH_ENDPOINT=https://your-region.cdn.hygraph.com/content/your-project-id/masterNEXT_PUBLIC_HYGRAPH_STUDIO_URL=https://your-region.hygraph.com # Defaults to https://app.hygraph.comHYGRAPH_TOKEN=your-permanent-auth-token # Optional: Required if your project uses authentication
#Add data attributes to content elements
#Simple fields
// app/recipes/[id]/page.tsxreturn (<main>{/* Title */}<h1data-hygraph-entry-id={recipe.id}data-hygraph-field-api-id="title">{recipe.title}</h1>{/* Description */}<divdata-hygraph-entry-id={recipe.id}data-hygraph-field-api-id="description"data-hygraph-rich-text-format="html"><div dangerouslySetInnerHTML={{ __html: recipe.description.html }} /></div>{/* Recipe Meta */}<divdata-hygraph-entry-id={recipe.id}data-hygraph-field-api-id="prepTime">{recipe.prepTime}</div><divdata-hygraph-entry-id={recipe.id}data-hygraph-field-api-id="cookTime">{recipe.cookTime}</div><divdata-hygraph-entry-id={recipe.id}data-hygraph-field-api-id="servings">{recipe.servings}</div><divdata-hygraph-entry-id={recipe.id}data-hygraph-field-api-id="difficulty">{recipe.difficulty}</div>{/* Hero Image */}<divdata-hygraph-entry-id={recipe.id}data-hygraph-field-api-id="heroImage">{/* Example image rendering */}{recipe.heroImage?.url && (<img src={recipe.heroImage.url} alt={recipe.title} />)}</div></main>);
#Basic components
These are direct children of the Recipe model, and are not nested inside other components.
#Nested components
These are nested inside other components and require a multi-link chain.
#Modular components
These can be one of multiple component types with different shapes.
#Full example
// app/recipes/[id]/page.tsximport { createComponentChainLink, createPreviewAttributes, withFieldPath } from '@hygraph/preview-sdk/core';// Basic fields<h1 data-hygraph-field-api-id="title" data-hygraph-entry-id={recipe.id}>{recipe.title}</h1><divdata-hygraph-field-api-id="description"data-hygraph-entry-id={recipe.id}data-hygraph-rich-text-format="html"><div dangerouslySetInnerHTML={{ __html: recipe.description.html }} /></div><div data-hygraph-field-api-id="prepTime" data-hygraph-entry-id={recipe.id}>{recipe.prepTime}min</div><div data-hygraph-field-api-id="categories">{recipe.categories.map((category) => (<span key={category.id}>{category.name}</span>))}</div>// Basic components// Ingredients{recipe.ingredients.map((ingredient, index) => {const chain = [createComponentChainLink('ingredients', ingredient.id)];const basePath = `ingredients.${index}`;const quantityAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'quantity',componentChain: chain,}),`${basePath}.quantity`);const unitAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'unit',componentChain: chain,}),`${basePath}.unit`);return (<div key={ingredient.id}><span>{ingredient.ingredient?.name}</span><span {...quantityAttributes}>{ingredient.quantity}</span><span {...unitAttributes}>{ingredient.unit}</span></div>);})}// Recipe Steps{recipe.recipeSteps.map((step, index) => {const chain = [createComponentChainLink('recipeSteps', step.id)];const stepBasePath = `recipeSteps.${index}`;const stepNumberAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'stepNumber',componentChain: chain,}),`${stepBasePath}.stepNumber`);const stepTitleAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'title',componentChain: chain,}),`${stepBasePath}.title`);const instructionAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'instruction',componentChain: chain,}),`${stepBasePath}.instruction`);return (<div key={step.id}><span {...stepNumberAttributes}>{step.stepNumber}</span>{step.title && <h3 {...stepTitleAttributes}>{step.title}</h3>}<divdangerouslySetInnerHTML={{ __html: step.instruction.html }}{...instructionAttributes}data-hygraph-rich-text-format="html"/></div>);})}// Nested components// Equipment within Recipe Steps{step.equipment.map((equip, equipIndex) => {const equipChain = [createComponentChainLink('recipeSteps', step.id),createComponentChainLink('equipment', equip.id)];const equipBasePath = `${stepBasePath}.equipment.${equipIndex}`;const nameAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'name',componentChain: equipChain,}),`${equipBasePath}.name`);const requiredAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'required',componentChain: equipChain,}),`${equipBasePath}.required`);return (<div key={equip.id}><span {...nameAttributes}>{equip.name}</span>{equip.required && <span {...requiredAttributes}>Required</span>}</div>);})}// Ingredients Used within Recipe Steps{step.ingredientsUsed.map((ingred, ingredIndex) => {const ingredChain = [createComponentChainLink('recipeSteps', step.id),createComponentChainLink('ingredientsUsed', ingred.id)];const ingredBasePath = `${stepBasePath}.ingredientsUsed.${ingredIndex}`;const ingredientNameAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'ingredientName',componentChain: ingredChain,}),`${ingredBasePath}.ingredientName`);return (<div key={ingred.id}><span {...ingredientNameAttributes}>{ingred.ingredientName}</span></div>);})}// Tips within Recipe Steps{step.tips.map((tip, tipIndex) => {const tipChain = [createComponentChainLink('recipeSteps', step.id),createComponentChainLink('tips', tip.id)];const tipBasePath = `${stepBasePath}.tips.${tipIndex}`;const titleAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'title',componentChain: tipChain,}),`${tipBasePath}.title`);const contentAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'content',componentChain: tipChain,}),`${tipBasePath}.content`);return (<div key={tip.id}><h5 {...titleAttributes}>{tip.title}</h5><divdangerouslySetInnerHTML={{ __html: tip.content.html }}{...contentAttributes}data-hygraph-rich-text-format="html"/></div>);})}// Modular components// Featured Content (Single){recipe.featuredContent && (() => {const section = recipe.featuredContent;const chain = [createComponentChainLink('featuredContent', section.id)];const basePath = 'featuredContent';switch (section.__typename) {case 'ProTip': {const iconAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'icon',componentChain: chain,}),`${basePath}.icon`);const titleAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'title',componentChain: chain,}),`${basePath}.title`);const contentAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'content',componentChain: chain,}),`${basePath}.content`);return (<div><div {...iconAttributes}>{section.icon}</div><h3 {...titleAttributes}>{section.tipTitle}</h3><divdangerouslySetInnerHTML={{ __html: section.tipContent.html }}{...contentAttributes}data-hygraph-rich-text-format="html"/></div>);}case 'VideoEmbed': {const titleAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'title',componentChain: chain,}),`${basePath}.title`);const videoUrlAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'videoUrl',componentChain: chain,}),`${basePath}.videoUrl`);return (<div><h3 {...titleAttributes}>{section.videoTitle}</h3><div {...videoUrlAttributes}>Video</div></div>);}case 'IngredientSpotlight': {const imageAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'image',componentChain: chain,}),`${basePath}.image`);const ingredientAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'ingredient',componentChain: chain,}),`${basePath}.ingredient`);const descriptionAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'description',componentChain: chain,}),`${basePath}.description`);return (<div><div {...imageAttributes}>Image</div><h3 {...ingredientAttributes}>{section.ingredient?.name}</h3><divdangerouslySetInnerHTML={{ __html: section.ingredientDescription.html }}{...descriptionAttributes}data-hygraph-rich-text-format="html"/></div>);}default:return null;}})()}// Additional Sections (Array){recipe.additionalSections.map((section, index) => {const chain = [createComponentChainLink('additionalSections', section.id)];const basePath = `additionalSections.${index}`;switch (section.__typename) {case 'ProTip': {const iconAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'icon',componentChain: chain,}),`${basePath}.icon`);const titleAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'title',componentChain: chain,}),`${basePath}.title`);const contentAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'content',componentChain: chain,}),`${basePath}.content`);return (<div key={section.id}><div {...iconAttributes}>{section.icon}</div><h3 {...titleAttributes}>{section.tipTitle}</h3><divdangerouslySetInnerHTML={{ __html: section.tipContent.html }}{...contentAttributes}data-hygraph-rich-text-format="html"/></div>);}case 'VideoEmbed': {const titleAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'title',componentChain: chain,}),`${basePath}.title`);return (<div key={section.id}><h3 {...titleAttributes}>{section.videoTitle}</h3></div>);}case 'IngredientSpotlight': {const imageAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'image',componentChain: chain,}),`${basePath}.image`);const ingredientAttributes = withFieldPath(createPreviewAttributes({entryId: recipe.id,fieldApiId: 'ingredient',componentChain: chain,}),`${basePath}.ingredient`);return (<div key={section.id}><div {...imageAttributes}>Image</div><h3 {...ingredientAttributes}>{section.ingredient?.name}</h3></div>);}default:return null;}})}