Disclaimer: This website requires Please enable JavaScript in your browser settings for the best experience.

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 your Next.js frontend to let content editors update live content directly from the Content Management System (CMS) interface. This set up connects your Next.js application with Optimizely CMS 12 and Optimizely Graph, supporting the fetching of draft content and synchronizing changes in real-time. Use the provided draft API route, page component, and OnPageEdit communication component to implement editable fields with data-epi-edit attributes, handle content updates, and maintain accurate work IDs for major changes. Once configured, editors can preview and modify content live, even in headless scenarios, ensuring a seamless authoring experience.

How it works

  1. CMS loads your frontend in an iframe for editing.
  2. The frontend detects draft mode and displays preview content.
  3. The communication script enables real-time editing between Content Management System (CMS) and frontend.
  4. Draft content is fetched through Optimizely Graph using draft mode capabilities.

Prerequisites

  • Optimizely CMS 12 or later
  • Optimizely Graph configured and connected
  • A deployed Next.js frontend using Graph API
  • Access to your CMS instance URL and Graph endpoint

Follow these steps to enable live preview and on-page editing in your Next.js frontend:

1. Configure CMS for headless on-page editing

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:

  • Add hostnames for both CMS and frontend applications.
  • Ensure both use the same protocol (preferably HTTPS).
  • Example:
    • CMS: https://localhost:5096/
    • Frontend: https://localhost:3000/

2. Configure the Next.js frontend

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,
      }
    ];
  },
};

3. Create the draft API 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);
}

4. Create the draft page component

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>
    </>
  );
}

5. Create the on-page edit component

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

Data attributes for editing

  • Use data-epi-edit="propertyName" to make fields editable.
  • Property names must match the CMS content type properties.
  • Use is-on-page-editing-block-container="true" for content areas.

URL patterns

  • 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

  1. Start CMS and frontend applications.
  2. Open a page in CMS edit mode.
  3. CMS loads the frontend in an iframe.
  4. Edit content in CMS.
  5. Changes display instantly in the iframe preview.