Help teams manage content creation and approval in a clear and structured way
Hygraph
Docs

#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:

  1. Hover over a tagged element in your preview to display an Edit button.
  2. Click the Edit button to open the corresponding field entry in Studio.
  3. 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

  1. Install the Preview SDK.
  2. Create a PreviewWrapper component to enable the preview functionality.
  3. Set environment variables.
  4. Add data attributes to your content elements.
  5. Set up the Preview widget in Studio
  6. 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

  1. Create a PreviewWrapper.tsx file in the components folder in your project directory. In this components/PreviewWrapper.tsx file, 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 (
    <HygraphPreview
    endpoint="https://your-region.cdn.hygraph.com/content/your-project-id/master"
    studioUrl="https://app.hygraph.com" // Replace by your Hygraph project's custom domain
    debug={true} // Optional: Enable console logging
    mode="iframe" // Optional: 'iframe' | 'standalone' | 'auto'
    onSave={(entryId) => { // Optional: Custom save handler
    console.log('Content saved:', entryId);
    router.refresh();
    }}
    overlay={{ // Optional: Customize overlay styling
    style: {
    borderColor: '#3b82f6',
    borderWidth: '2px',
    },
    button: {
    backgroundColor: '#3b82f6',
    color: 'white',
    },
    }}
    sync={{
    fieldFocus: true, // Optional: Enable field focus sync from Studio
    fieldUpdate: false, // Optional: Apply live field updates to Preview
    }}
    >
    {children}
    </HygraphPreview>
    );
    }
  2. In the app/layout.tsxfile, import the PreviewWrapper component.

    // app/layout.tsx
    import { PreviewWrapper } from '@/components/PreviewWrapper';
    export default function RootLayout({ children }) {
    return (
    <html>
    <body>
    <PreviewWrapper>{children}</PreviewWrapper>
    </body>
    </html>
    );
    }

Configuration properties

PropertyRequired / OptionalDescription
endpointRequiredHygraph Content API endpoint. To learn how to retrieve the Content API endpoint, see our docs.
studioUrlRequiredYour 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.
debugOptionalEnables verbose console logs to diagnose attribute issues.
modeOptionalForces a specific mode. Options: 'iframe' | 'standalone' | 'auto'. Auto-detection works for most cases.
onSaveOptionalRuns after Hygraph reports a save and receives the entry ID for targeted revalidation.
overlayOptionalCustomize overlay border and button appearance.
sync.fieldFocusOptionalFocuses the field in Studio when clicking an overlay.
sync.fieldUpdateOptionalUpdates the preview immediately when field updates happen in Studio. Defaults to false.
allowedOriginsOptionalExtends 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.local
NEXT_PUBLIC_HYGRAPH_ENDPOINT=https://your-region.cdn.hygraph.com/content/your-project-id/master
NEXT_PUBLIC_HYGRAPH_STUDIO_URL=https://your-region.hygraph.com # Defaults to https://app.hygraph.com
HYGRAPH_TOKEN=your-permanent-auth-token # Optional: Required if your project uses authentication

#Add data attributes to content elements

#Simple fields

// app/recipes/[id]/page.tsx
return (
<main>
{/* Title */}
<h1
data-hygraph-entry-id={recipe.id}
data-hygraph-field-api-id="title"
>
{recipe.title}
</h1>
{/* Description */}
<div
data-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 */}
<div
data-hygraph-entry-id={recipe.id}
data-hygraph-field-api-id="prepTime"
>
{recipe.prepTime}
</div>
<div
data-hygraph-entry-id={recipe.id}
data-hygraph-field-api-id="cookTime"
>
{recipe.cookTime}
</div>
<div
data-hygraph-entry-id={recipe.id}
data-hygraph-field-api-id="servings"
>
{recipe.servings}
</div>
<div
data-hygraph-entry-id={recipe.id}
data-hygraph-field-api-id="difficulty"
>
{recipe.difficulty}
</div>
{/* Hero Image */}
<div
data-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.tsx
import { 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>
<div
data-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>}
<div
dangerouslySetInnerHTML={{ __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>
<div
dangerouslySetInnerHTML={{ __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>
<div
dangerouslySetInnerHTML={{ __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>
<div
dangerouslySetInnerHTML={{ __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>
<div
dangerouslySetInnerHTML={{ __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;
}
})}