Dev guideRecipesAPI ReferenceChangelog
Dev guideRecipesUser GuidesNuGetDev CommunityOptimizely AcademySubmit a ticketLog In
Dev guide

Live preview with Next.js (headless)

Enable real-time on-page editing in a headless Optimizely CMS 12 site using Next.js by syncing draft content with the frontend.

Enable on-page editing in a Next.js front end so content editors update live content directly from the Optimizely Content Management System (CMS) interface. This configuration connects the Next.js application to Optimizely CMS 12 and Optimizely Graph, fetches draft content, and syncs changes in real time. The draft API route, page component, and OnPageEdit communication component drive editable fields marked with data-epi-edit attributes, handle content updates, and keep work IDs in sync after major changes. After configuration, editors preview and modify content live in headless scenarios without leaving the CMS authoring flow.

How it works

The live preview pipeline links the CMS editor, the Next.js front end, and Optimizely Graph so edits made in CMS render in the preview iframe without a full reload.

  1. CMS loads the front end in an iframe for editing.
  2. The front end detects draft mode and displays preview content.
  3. The communication script enables real-time editing between Optimizely CMS and the front end.
  4. Optimizely Graph fetches draft content using draft mode capabilities.

Prerequisites

Confirm the CMS, Graph, and front-end pieces are in place before configuring live preview.

  • Optimizely CMS 12 or later.
  • Optimizely Graph configured and connected.
  • A deployed Next.js front end that uses the Graph API.
  • Access to the CMS instance URL and Graph endpoint.

Configure CMS for headless on-page editing

Update the CMS application so it allows draft content sync, exposes Graph credentials to the front end, and serves the headless application through CORS and redirect helpers.

Enable draft content sync in 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"
  }
}

Create headless extensions.

// Extensions/HeadlessExtensions.cs
using EPiServer.ContentApi.Core.DependencyInjection;
using EPiServer.Web;

public static class HeadlessExtensions
{
    public static IServiceCollection AddHeadlessOnPageEditing(this IServiceCollection services)
    {
        return services
            .ConfigureForExternalTemplates()
            .Configure<ExternalApplicationOptions>(options => options.OptimizeForDelivery = true);
    }

    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)
    {
        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");
            }
            return Task.CompletedTask;
        });
        return app;
    }
}

Update Startup.cs.

public class Startup
{
    private readonly string? _frontendUri;

    public Startup(IConfiguration configuration)
    {
        _frontendUri = configuration.GetValue<string>("Headless:FrontEndUri");
    }

    public void ConfigureServices(IServiceCollection services)
    {
        // Existing services...
        services.AddHeadlessOnPageEditing();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        // Existing configuration...
        app.AddCorsForFrontendApp(_frontendUri);
        app.AddRedirectToCms();
    }
}

Configure and manage websites in CMS

Register the CMS and front-end hosts so CMS routes preview traffic to the Next.js application without protocol or host mismatches.

  • Add hostnames for both CMS and front-end applications.
  • Use the same protocol on both hosts (HTTPS is preferred).
  • Use the following example hosts:
    • CMShttps://localhost:5096/
    • Front endhttps://localhost:3000/

Configure the Next.js front end

Configure the Next.js application so it knows the CMS host, allows the CMS to embed it in an iframe, and routes preview URLs to the draft API.

Set the CMS URL in .env.local.

# .env.local
NEXT_PUBLIC_CMS_URL="https://localhost:5096"

Enable iframe support in next.config.mjs.

// next.config.mjs
export default {
  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'}`,
          },
        ],
      },
    ];
  },

  async redirects() {
    return [
      {
        source: '/episerver/CMS/Content/:slug*',
        destination: '/api/draft/:slug*',
        permanent: false,
      }
    ];
  },
};

Create the draft API route

Handle preview URLs from CMS by parsing the locale, content ID, and work ID, enabling Next.js draft mode, and redirecting to the matching draft page or block route.

Create a draft API handler in 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;

  const epiEditMode = searchParams.get('epieditmode');
  
  // Remove leading '/api/draft/' from pathname
  let path = pathname.replace(/^\/api\/draft\//, '');
  
  // Check if it's a block or page
  const isBlock = path.startsWith('contentassets');
  path = isBlock ? path.replace("contentassets/", "") : path;
  
  // Extract locale and IDs (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 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);
}

Create the draft page component

Render the draft content the editor sees inside the CMS iframe. The component fetches the working version from Optimizely Graph, injects the CMS communication script, and exposes editable fields through data-epi-edit attributes.

Create the draft page in src/app/draft/page.tsx.

import { draftMode } from 'next/headers';
import { notFound } from 'next/navigation';
import Script from 'next/script';
import { getPageContentById } from '~/cms/helpers';
import OnPageEdit from '~/components/draft/OnPageEdit';

// Force dynamic rendering for draft content
export const revalidate = 0;
export const dynamic = 'force-dynamic';

interface DraftPageProps {
  searchParams: {
    locale: string;
    id: string;
    workId: string;
    epiEditMode: string;
  };
}

export default async function DraftPage({ searchParams }: DraftPageProps) {
  const { isEnabled: isDraftModeEnabled } = await draftMode();
  if (!isDraftModeEnabled) {
    return notFound();
  }

  // Fetch draft content using Graph with authentication
  const page = await getPageContentById({
    id: searchParams.id,
    workId: searchParams.workId,
    locale: searchParams.locale,
    preview: true, // This triggers draft content fetching
  });

  if (!page) return notFound();

  return (
    <>
      {/* Include CMS communication script */}
      <Script 
        src={`${process.env.NEXT_PUBLIC_CMS_URL}/util/javascript/communicationinjector.js`} 
        strategy="afterInteractive"
      />
      
      {/* On-page editing component */}
      <OnPageEdit
        currentRoute={`/draft?${new URLSearchParams(searchParams).toString()}`}
        workId={searchParams.workId}
      />
      
      {/* Editable content with data-epi-edit attributes */}
      {page.title && (
        <h1 data-epi-edit="title">{page.title}</h1>
      )}
      
      {page.teaserText && (
        <p data-epi-edit="teaserText">{page.teaserText}</p>
      )}
      
      <div data-epi-edit="mainContentArea" is-on-page-editing-block-container="true">
        {/* Render your content blocks here */}
      </div>
    </>
  );
}

Create the on-page edit component

Subscribe to the CMS contentSaved event in the browser, update editable DOM nodes with new values, and refresh the route when CMS issues a new work ID for a major content update.

Create the communication component in 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 ContentSavedEventArgs {
  contentLink: string;
  previewUrl: string;
  editUrl: string;
  properties: Array<{
    name: string;
    value: string;
    successful: boolean;
    validationErrors: string;
  }>;
}

interface OnPageEditProps {
  workId: string;
  currentRoute: string;
}

const OnPageEdit = ({ workId, currentRoute }: OnPageEditProps) => {
  const router = useRouter();
  const prevMessageRef = useRef<ContentSavedEventArgs | null>(null);

  useEffect(() => {
    const handleContentSaved = (event: ContentSavedEventArgs) => {
      // Prevent duplicate event processing
      if (prevMessageRef.current && 
          JSON.stringify(prevMessageRef.current) === JSON.stringify(event)) {
        return;
      }
      prevMessageRef.current = event;

      // Update DOM elements with new content
      event.properties?.forEach((prop) => {
        if (!prop.successful) return;

        const elements = document.querySelectorAll<HTMLElement>(
          `[data-epi-edit="${prop.name}"]`
        );

        elements.forEach((el) => {
          // Skip elements inside block containers
          if (el.closest('[is-on-page-editing-block-container]')) {
            return;
          }
          el.textContent = prop.value;
        });
      });

      // Handle workId changes for major content updates
      const [, newWorkId] = event?.contentLink?.split('_');
      if (newWorkId && newWorkId !== workId) {
        const newUrl = currentRoute?.replace(
          `workId=${workId}`,
          `workId=${newWorkId}`
        );
        router.push(newUrl);
      }
    };

    // Subscribe to CMS events when epi API is available
    let interval: NodeJS.Timeout;
    const trySubscribe = () => {
      const win = window as any;
      if (win.epi) {
        win.epi.subscribe('contentSaved', handleContentSaved);
        clearInterval(interval);
      }
    };

    interval = setInterval(trySubscribe, 500);
    trySubscribe();

    return () => {
      clearInterval(interval);
      const win = window as any;
      if (win.epi?.unsubscribe) {
        win.epi.unsubscribe('contentSaved', handleContentSaved);
      }
    };
  }, [currentRoute, router, workId]);

  return null;
};

export default OnPageEdit;

Key implementation notes

Review the following implementation details so the editable markup, preview URLs, and end-to-end test path stay aligned with the CMS contract.

Data attributes

The data-epi-edit attribute marks DOM nodes that the CMS editor can update inline, and a separate attribute scopes block-level containers.

  • Use data-epi-edit="propertyName" to mark a field as editable.
  • Match property names to the CMS content type properties.
  • Use is-on-page-editing-block-container="true" on content areas.

URL patterns

CMS preview links encode the locale, content ID, work ID, and edit mode flag in the URL. The draft API route parses these segments into search parameters for the draft page.

  • Pages/episerver/CMS/Content/en/,,5_8?epieditmode=true
  • Blocks/episerver/CMS/contentassets/en/guid/,,7_10?epieditmode=true
  • URLs include locale, content ID, work ID, and edit mode flag.

Test on-page editing

Run an end-to-end check to confirm the front end picks up edits made in CMS.

  1. Start the CMS and front-end applications.
  2. Open a page in CMS edit mode so CMS loads the front end in an iframe.
  3. Edit content in CMS and confirm the iframe preview displays the changes.