Going Headless: On-Page Editing with Optimizely Graph and Next.js
Introduction
On-page editing is one of the standout features of Optimizely CMS, giving editors the power to update content directly on the site as they see it. But when you move to a headless architecture, bringing that seamless editing experience to your custom frontend can seem daunting. The good news? It's absolutely possible and not as complicated as you might think!
In this article, I'll walk you through how to enable on-page editing in your own solution using Next.js, so your editors can enjoy the best of both worlds: modern headless flexibility and intuitive in-context editing.
How does On-Page Editing work in a headless setup?
Here's how the process typically works:
- Optimizely CMS loads your frontend site inside an iframe.
- Your frontend detects that it's running within the CMS iframe and automatically switches to draft mode, displaying preview versions of pages or blocks.
By including the communicationinjector.js script (provided by your Optimizely CMS app), your frontend can listen for and respond to events triggered from the CMS editor.
Step 1: Display the frontend in an iframe
There are a few things we need to do to display our frontend app in an iframe inside the CMS.
First things first-tell Optimizely Graph to include all draft content by adding "AllowSyncDraftContent": true. When enabled, Graph indexes draft content and makes it available to the frontend. Let's also add Headless:FrontEndUri so we can configure CORS for the frontend site.
//appsettings.json
{
"Optimizely": {
"ContentGraph": {
"GatewayAddress": "https://cg.optimizely.com",
"AppKey": "YOUR_APP_KEY",
"Secret": "YOUR_SECRET_KEY",
"SingleKey": "YOUR_SINGLE_KEY",
"AllowSyncDraftContent": true
}
},
"Headless": {
"FrontEndUri": "https://localhost:3000"
}
}
To keep things clean, add an extension we'll use in Startup.cs
// HeadlessExtensions.cs
using EPiServer.ContentApi.Core.DependencyInjection;
using EPiServer.Web;
public static class HeadlessExtensions
{
public static IServiceCollection AddHeadlessOnPageEditing(this IServiceCollection serviceCollection)
{
serviceCollection
// Configure our CMS to use External Templates and to OptimizeForDelivery.
// This will allow the Edit UI to load the decoupled delivery site into On Page Edit.
// It will also instruct the overlay's bounding boxes to include floating instead of inline editors.
// This is required to avoid cross-origin issues between the domain of the iframe and of the CMS itself.
.ConfigureForExternalTemplates()
.Configure<ExternalApplicationOptions>(options => options.OptimizeForDelivery = true)
return serviceCollection;
}
public static IApplicationBuilder AddCorsForFrontendApp(this IApplicationBuilder app, string? frontendUri)
{
if (!string.IsNullOrWhiteSpace(frontendUri))
{
app.UseCors(b => b
.WithOrigins($"{frontendUri}", "*")
.WithExposedContentDeliveryApiHeaders()
.WithHeaders("Authorization")
.AllowAnyMethod()
.AllowCredentials());
}
return app;
}
public static IApplicationBuilder AddRedirectToCms(this IApplicationBuilder app)
{
// Since it's a headless setup we can redirect user from "/" directly to CMS
app.UseStatusCodePages(context =>
{
if (context.HttpContext.Response.HasStarted == false &&
context.HttpContext.Response.StatusCode == StatusCodes.Status404NotFound &&
context.HttpContext.Request.Path == "/")
{
context.HttpContext.Response.Redirect("/episerver/cms");
// or /ui/cms if you use OptiID or have changed path to your CMS
//context.HttpContext.Response.Redirect("/ui/cms")
}
return Task.CompletedTask;
});
return app;
}
}
Now use this extension in Startup.cs
// Startup.cs
public class Startup
{
private readonly IWebHostEnvironment _webHostingEnvironment;
private readonly IConfiguration _configuration;
private readonly string? _frontendUri;
public Startup(IWebHostEnvironment webHostingEnvironment, IConfiguration configuration)
{
// ...
_frontendUri = _configuration.GetValue<string>("Headless:FrontEndUri");
}
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddHeadlessOnPageEditing();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ...
app.AddCorsForFrontendApp(_frontendUri);
app.AddRedirectToCms();
}
}
Neat and clean. Next, enable the frontend to be loaded in an iframe. Add an environment variable:
// .env.local
NEXT_PUBLIC_CMS_URL="https://localhost:5096"
and use it in the Next.js config:
// next.config.mjs
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{
key: 'Content-Security-Policy',
value: `frame-ancestors 'self' ${process.env.NEXT_PUBLIC_CMS_URL || 'localhost:5096'}`,
},
],
},
]
},
Finally, go to "Manage Websites" and define hosts for both the CMS and the frontend apps. Set this up correctly or the CMS won't be able to display previews from a different host.
In example below:
- CMS app: https://localhost:5096/
- Frontend client: https://localhost:3000/
Run both CMS and frontend on the same protocol, preferably HTTPS. Otherwise the CMS won't load the iframe.
Now when you edit a page, the CMS should load the frontend, likely showing a 404. That's because Optimizely opens the iframe with a predefined URL that we need to handle.
Step 2: Draft mode and page preview
To make On-Page Editing work in our Next.js app we need to use draft mode. Draft Mode allows you to preview draft content from your headless CMS in your Next.js application. This is useful for static pages that are generated at build time as it allows you to switch to dynamic rendering and see the draft changes without having to rebuild your entire site.
When the CMS loads a preview in an iframe it will hit one of two URLs:
- https://localhost:3000/episerver/CMS/Content/en/,,5_8?epieditmode=true for pages
- https://localhost:3000/episerver/CMS/contentassets/en/77364cdc2802407b8b8cae45b65bc16e/,,7_10?epieditmode=true for blocks
These URLs contain the following information:
- en - content locale
- 5_8 or 7_10 - ID (5 for page, 7 for block) and workId (8 for page, 10 for block). id identifies the content; workId identifies the version.
- epieditmode - distinguishes page preview (epieditmode=false) from On-Page Editing (epieditmode=true).
Knowing these URLs, set up redirects in your Next.js app:
// next.config.mjs
async redirects() {
return [
{
source: '/episerver/CMS/Content/:slug*',
destination: '/api/draft/:slug*',
permanent: false,
}
]
},
If you're using OptiID or changed the CMS URL, you might need to replace episerver with ui or another prefix.
Now all requests to /episerver/CMS/Content/* and /episerver/CMS/contentassets/* will be redirected to our draft API route. Here we need to:
- Enable draft mode by calling (await draftMode()).enable(). This allows bypassing static generation and rendering on-demand at request time
- Extract parameters from URL
- Redirect to a draft (for page or block) and pass extracted parameters
// src/app/api/draft/[...slug]/route.ts"
import { redirect, notFound } from 'next/navigation';
import { NextRequest } from 'next/server';
import { draftMode } from 'next/headers'
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const { searchParams, pathname } = url;
// There are 2 different type of URLs to handle here...
// For Page:
// /api/draft/en/,,5_8?epieditmode=true
// For Blocks:
// /api/draft/contentassets/en/77364cdc2802407b8b8cae45b65bc16e/,,7_10?epieditmode=true
// To get the value of a search param, use .get('<paramName>')
const epiEditMode = searchParams.get('epieditmode');
// Remove leading '/api/draft/' from pathname
let path = pathname.replace(/^\/api\/draft\//, '');
// Assess type of content
const isBlock = path.startsWith('contentassets');
path = isBlock ? path.replace("contentassets/", "") : path;
// Extract locale + ids (Optimizely-specific pattern: /en/,,5_8)
const segments = path.split("/");
const locale = segments[0] || "en";
const idsPart = segments[segments.length - 1] || "";
const [, ids = ""] = idsPart.split(",,");
const [id = "0", workId = "0"] = ids.split("_");
// Validate all required fields
if (!locale || !id || !workId || !epiEditMode) {
return notFound();
}
// Enable draft mode
(await draftMode()).enable()
const newSearchParams = new URLSearchParams({
locale,
id,
workId,
epiEditMode,
});
const newUrl = isBlock
? `/draft/block?${newSearchParams.toString()}`
: `/draft?${newSearchParams.toString()}`;
redirect(`${newUrl}`);
}
Step 3: Include the communicationinjector.js script
To enable seamless communication between your draft site and the CMS for on-page editing, you must add the communicationinjector.js script to your draft layout. This script, served from your CMS instance, is required for listening to content save events and enabling real-time editing capabilities.
Include it in your layout file:
<Script src={`${process.env.NEXT_PUBLIC_CMS_URL}/util/javascript/communicationinjector.js`} />
Step 4: Display the draft for a page.
A key aspect is the data-epi-edit attribute, which tells Optimizely which field is editable on the page. In the example above, we set its value to "title", matching the property name in the CMS.
// src/app/draft/page.tsx
import { renderBlocks } from '@repo/helpers';
import { draftMode } from 'next/headers';
import { notFound } from 'next/navigation';
import * as blockComponents from '~/blocks';
import { getPageContentById } from '~/cms/helpers';
import OnPageEdit from '~/components/draft/OnPageEdit';
import type { AllBlocks } from '~/types';
import type { OptiPreviewPageProps } from '~/types-legacy';
// Ensure this page is always rendered on-demand and never statically cached
export const revalidate = 0;
export const dynamic = 'force-dynamic';
export default async function PreviewPage({
searchParams,
}: OptiPreviewPageProps) {
const { isEnabled: isDraftModeEnabled } = await draftMode();
if (!isDraftModeEnabled) {
return notFound();
}
// Fetch the page content in draft mode using the provided search parameters
const page = await getPageContentById({
id: searchParams.id,
workId: searchParams.workId,
locale: searchParams.locale,
preview: true,
});
if (!page) return notFound();
return (
<>
<OnPageEdit
currentRoute={`/draft?${new URLSearchParams(searchParams).toString()}`}
workId={searchParams.workId}
/>
{/* Render the page title with Optimizely's on-page editing attribute */}
{page.title && <h2 data-epi-edit="title">{page.title}</h2>}
<div data-epi-edit="mainContentArea" is-on-page-editing-block-container="true">
{renderBlocks<AllBlocks>(page.blocks, blockComponents, {
showDataOnMissingBlock: true,
arbitraryData: { searchParams },
weave: ['backgroundColor'],
})}
</div>
</>
);
}
Ensure that your getPageContentById function correctly handles the preview flag. When preview is true, you must use a different authentication method for Optimizely Graph to access draft content. Without this, fetching draft content will not work.
For details on authenticating with Optimizely Graph, see the official documentation.
This page layout uses the OnPageEdit component - the cherry on top that handles communication with the CMS.
// src/components/draft/OnPageEdit.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
interface EpiAPI {
subscribe: (event: string, handler: (event: any) => void) => void;
unsubscribe?: (event: string, handler: (event: any) => void) => void;
}
interface WindowWithEpi extends Window {
epi?: EpiAPI;
}
interface ContentSavedProperty {
name: string;
value: string;
successful: boolean;
validationErrors: string;
}
interface ContentSavedEventArgs {
contentLink: string;
previewUrl: string;
editUrl: string;
properties: Array<ContentSavedProperty>;
}
interface OnPageEditProps {
workId: string;
currentRoute: string;
}
// This component listens for changes made in the Optimizely CMS editor and updates the preview of a page or block accordingly.
// When a change is made in the CMS, Optimizely emits a "contentSaved" event via the communicationinjector.js script.
// The event includes a list of updated properties. For significant changes, Optimizely may increment the workId of the edited content,
// returning the new workId as part of the content URL in the "contentSaved" event.
const OnPageEdit = ({
workId,
currentRoute,
}: OnPageEditProps) => {
const router = useRouter();
// Listen for "contentSaved" events from the CMS editor, which may fire multiple times per change.
// To avoid processing duplicate events, keep track of the last handled event and only process new ones.
const prevMessageRef = useRef<ContentSavedEventArgs | null>(null);
useEffect(() => {
// Handle content saved events from Optimizely CMS
const handleContentSaved = (event: any) => {
const message = event as ContentSavedEventArgs;
// Check if the event is a duplicate
const prevMessage = prevMessageRef.current;
if (
prevMessage &&
JSON.stringify(prevMessage) === JSON.stringify(message)
) {
// Skip the event
return;
}
prevMessageRef.current = message;
// Since Optimizely Graph is external service, it may take some time to propagate the changes.
// That's why simple "refresh" is not sufficient to provide seamless editing experience.
// We need to look inside "contentSaved" event and update the DOM with the new content.
message.properties?.forEach((prop) => {
if (!prop.successful) return;
// Find matching elements in the DOM
const elements = document.querySelectorAll<HTMLElement>(
`[data-epi-edit="${prop.name}"]`,
);
elements.forEach((el) => {
// Skip elements inside content areas that are marked as "is-on-page-editing-block-container"
if (el.closest('[is-on-page-editing-block-container]')) {
return;
}
// Replace text content
el.textContent = prop.value;
});
});
// When a major content change occurs, Optimizely increments the workId and provides a new draft URL.
// If the workId has changed, update the URL to fetch the latest draft content from Graph.
// Extract the new workId from the contentLink (format: "id_workId").
const [, newWorkId] = message?.contentLink?.split('_');
if (newWorkId && newWorkId !== workId) {
const newUrl = currentRoute?.replace(
`workId=${workId}`,
`workId=${newWorkId}`,
);
router.push(newUrl);
}
};
// -----------------------------------------------
// Subscribe for Optimizely CMS events
// -----------------------------------------------
// Next.js pages are first server-rendered and then hydrated on the client.
// window.epi (Optimizely API) may not exist immediately on hydration,
// so we poll every 500ms until it becomes available.
let interval: NodeJS.Timeout;
const trySubscribe = () => {
const win = window as WindowWithEpi;
if (win.epi) {
win.epi.subscribe('contentSaved', handleContentSaved);
clearInterval(interval); // Stop polling once subscribed
}
};
interval = setInterval(trySubscribe, 500);
trySubscribe(); // try immediately too
// -----------------------------------------------
// Cleanup
// -----------------------------------------------
// Avoid duplicate subscriptions or memory leaks
return () => {
clearInterval(interval);
const win = window as WindowWithEpi;
if (win.epi) {
win.epi.unsubscribe?.('contentSaved', handleContentSaved);
}
};
}, [currentRoute, router, workId]);
return null;
};
export default OnPageEdit;
Summary
I hope this article has helped clarify how to implement on-page editing in your own headless Next.js solution. With these steps, you should be well-equipped to bring a seamless editing experience to your editors, combining the flexibility of headless architecture with the intuitive in-context editing of Optimizely CMS.
Thanks for following along - happy building!
Comments