#Click to Edit - Next.js App Router
This page walks through implementing Click to Edit in a Next.js App Router project using the Hygraph Preview SDK. By the end, editors can click any instrumented element in the preview to jump directly to that field in Studio.
For the general setup overview and configuration reference, see Click to Edit setup. Examples for other supported frameworks are available at:
If you are instrumenting a frontend you did not build from scratch, start with the PreviewWrapper component and a single simple field. Confirm the Edit button appears and the save refresh works before adding component attributes. Components require additional data from your GraphQL queries.
#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.
- Verify the setup.
#Install the Preview SDK
To install the Preview SDK, run the following command:
npm install @hygraph/preview-sdk
#Create the PreviewWrapper component
The PreviewWrapper initializes the SDK and wraps your application content. The HygraphPreview component handles iframe and standalone mode detection automatically.
#Step 1: Create PreviewWrapper.tsx
Create components/PreviewWrapper.tsx:
// 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={process.env.NEXT_PUBLIC_HYGRAPH_ENDPOINT!}studioUrl={process.env.NEXT_PUBLIC_HYGRAPH_STUDIO_URL}debug={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>);}
#Step 2: Wrap children in layout.tsx
The PreviewWrapper must wrap {children} at the layout level. In Next.js App Router, {children} represents the rendered content of the active route. If the wrapper is absent, the SDK cannot register page content and Click to Edit will not work.
Import and apply the PreviewWrapper component in app/layout.tsx:
// 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
Add the following to .env.local in your project's root directory. If you already have these from live preview setup, skip this step.
# .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
Content API endpoint: Find this under Project Settings > Access > Endpoints > High Performance Content API. For more information, see our documentation on the Content API.
Hygraph Studio base URL: Copy from your browser's address bar in Studio. Ensure it does not end with /. Otherwise, Click to Edit will not work in standalone mode, outside of Hygraph Studio. Example: https://studio-eu-central-1-shared-euc1-02.hygraph.com.
Permanent Auth Token: Create under Project Settings > Access > Permanent Auth Tokens. Set the default content stage to DRAFT. Only required if your project enforces authentication on Content API requests. For more information, see our dedicated documentation on Permanent Auth Tokens.
#Add data attributes to content elements
Data attributes (data-hygraph-*) connect your rendered elements to specific Hygraph fields. The SDK reads these attributes and attaches Edit overlays automatically. The same attributes work for variants; no additional instrumentation is required. For the full attribute reference, see Add data attributes to content elements.
The examples below use a recipe model. To bootstrap the same Hygraph project used here, follow the project setup instructions.
#Simple fields
Add data-hygraph-entry-id and data-hygraph-field-api-id to any element rendering a Hygraph field value.
// 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>);
#Component fields
Components require the data-hygraph-component-chain attribute so Studio can navigate to the correct nested field instance. Use the helper functions from @hygraph/preview-sdk/core:
import {createComponentChainLink,createPreviewAttributes,withFieldPath,} from '@hygraph/preview-sdk/core';
The instanceId in each chain link is the id field of the component instance returned in your GraphQL query. It is not the component type's API ID. Query for id on every component you want to instrument. See the GraphQL example in Basic components below.
#Basic components
These are direct children of the Recipe model, and are not nested inside other components. Each one uses a single-link chain.
GraphQL query to retrieve instanceId values for basic components:
query GetRecipe($id: ID!) {recipe(where: { id: $id }, stage: DRAFT) {idtitleingredients {id # instanceId for the ingredient componentquantityunit}recipeSteps {id # instanceId for the step componentstepNumberinstruction { html }}}}
#Nested components
Nested components require a multi-link chain, ordered from the outermost to the innermost component.
GraphQL query to retrieve instanceId values for nested components. Extend your basic component query:
recipeSteps {idstepNumberinstruction { html }equipment {id # instanceId for nested equipmentnamerequired}tips {id # instanceId for nested tipstitlecontent { html }}}
#Modular components
Modular components can be one of several types. Use __typename to branch and build the chain per type.
#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;}})}
#Verify the setup
- Open an entry in Studio for the model you configured.
- In the right sidebar, click Open live preview. The preview should load alongside the entry form.
- Hover over an element you tagged with
data-hygraph-*attributes. An Edit button should appear. - Click Edit. Studio should scroll to and focus the corresponding field in the entry form.
- Edit the field value and click Save & Preview. The preview should refresh and show the updated content.
If you are working with variants, no additional setup is required. Clicking a tagged element while a variant is open focuses the field directly in the variant overlay.
If the Edit button does not appear at step 3, add debug={true} to your HygraphPreview component and check the browser console for missing attribute warnings. For component fields, confirm your GraphQL query includes the id field on each component and that the instanceId values in your chain match what the query returns. For more information, see Troubleshooting.
#Related docs
- Click to Edit setup: General setup steps, attribute reference, Studio widget configuration, and troubleshooting.
- Live preview setup: Configure the Studio preview iframe before deploying Click to Edit to editors.