Click-to-Edit Overlays That Map Preview Elements to CMS Fields

For CMSs without a built-in visual editor, or for pages that combine several content sources, this guide from Live Editing Integration Patterns builds the click-to-edit layer yourself: annotate rendered elements with the entry and field they came from, draw a lightweight overlay in preview mode, and open the right field in the CMS when an editor clicks.

The core idea is small. Every element that renders a CMS field gets two data attributes, an entry id and a field path. In preview mode, a script finds annotated elements under the pointer or keyboard focus, draws an outline and a label, and on activation opens a deep link into the CMS editor, or posts a message to the studio if the preview runs inside it. The pattern works with Strapi, Directus, Hygraph, Payload or a custom backend, and it is also how teams add click-to-edit for content that the CMS’s own SDK does not know about, such as product data merged in from a commerce API.

From annotated element to open fieldComponents render data attributes for entry id and field path; in preview mode the overlay script detects hover or focus on annotated elements, draws an outline, and on click builds a deep link to the CMS editor or posts a message to the embedding studio.Componentdata-cms-entry, data-cms-fieldRendered DOMOverlay scriptpreview onlyDeep linkeditor URLpostMessageto studiohover, focusopen in tabembedded
Components only add attributes; all overlay behaviour lives in one preview-only script.

The Problem

An agency maintains a Directus-backed site for a university. Editors navigate the admin by collection and item id, and finding “the text under the second image on the admissions page” means guessing which collection holds it and searching. The team tried to add a visual editing product, but the page combines Directus items, course data from a separate student information system and a few hard-coded components, so no off-the-shelf overlay knew where each piece came from. Editors asked for one thing: click something on the preview and land on the field that controls it.

How the Mapping Works

The mapping is data, produced by the components that render CMS content. A helper returns the attributes for an entry and field:

  • data-cms-source names the system, such as directus or sis, so the overlay can build the right link.
  • data-cms-entry holds the item id and, if needed, the collection.
  • data-cms-field holds the field path, such as blocks.2.caption, for editors that support deep links to fields.

In production, the helper returns an empty object, so published HTML carries no attributes. In preview, it returns the attributes, and the overlay script, also loaded only in preview, reads them. Because the attributes come from the same data that renders the element, they cannot drift from the content.

Deep link formats for common headless CMSsThe editor URL pattern to open an item, and whether a field can be targeted, for Directus, Strapi, Payload and Hygraph.CMSItem deep linkField targetingDirectus/admin/content/<collection>/<id>item only, field shown in tooltipStrapi v5/admin/content-manager/collection-types/<uid>/<documentId>item onlyPayload/admin/collections/<slug>/<id>item onlyHygraphproject entry URL by model and iditem onlyCustom studioyour routefield anchor you define
Where fields cannot be targeted directly, opening the item and highlighting the field label in the overlay tooltip is a good compromise.

Implementation

TSX
// lib/cms-edit-attrs.ts
export type CmsSource = "directus" | "sis";

export interface EditTarget {
  source: CmsSource;
  collection: string;
  id: string | number;
  field?: string;
}

export function editAttrs(target: EditTarget, preview: boolean): Record<string, string> {
  if (!preview) return {};
  return {
    "data-cms-source": target.source,
    "data-cms-entry": `${target.collection}:${target.id}`,
    ...(target.field ? { "data-cms-field": target.field } : {}),
  };
}

// components/PreviewOverlay.tsx: loaded only in preview mode
"use client";
import { useEffect, useRef, useState } from "react";

const EDIT_URLS: Record<string, (collection: string, id: string) => string> = {
  directus: (c, id) => `${process.env.NEXT_PUBLIC_DIRECTUS_URL}/admin/content/${c}/${id}`,
  sis: (c, id) => `${process.env.NEXT_PUBLIC_SIS_URL}/courses/${id}/edit`,
};

interface Box {
  top: number;
  left: number;
  width: number;
  height: number;
  label: string;
  href: string;
}

function targetFor(el: Element | null): HTMLElement | null {
  return el instanceof HTMLElement ? el.closest<HTMLElement>("[data-cms-entry]") : null;
}

export function PreviewOverlay() {
  const [box, setBox] = useState<Box | null>(null);
  const raf = useRef(0);

  useEffect(() => {
    const show = (el: HTMLElement | null) => {
      cancelAnimationFrame(raf.current);
      raf.current = requestAnimationFrame(() => {
        if (!el) return setBox(null);
        const [collection, id] = (el.dataset.cmsEntry ?? ":").split(":");
        const source = el.dataset.cmsSource ?? "directus";
        const r = el.getBoundingClientRect();
        setBox({
          top: r.top + window.scrollY,
          left: r.left + window.scrollX,
          width: r.width,
          height: r.height,
          label: `${collection}${el.dataset.cmsField ? ` · ${el.dataset.cmsField}` : ""}`,
          href: EDIT_URLS[source]?.(collection, id) ?? "#",
        });
      });
    };
    const onOver = (e: PointerEvent) => show(targetFor(e.target as Element));
    const onFocus = (e: FocusEvent) => show(targetFor(e.target as Element));
    document.addEventListener("pointerover", onOver);
    document.addEventListener("focusin", onFocus);
    return () => {
      document.removeEventListener("pointerover", onOver);
      document.removeEventListener("focusin", onFocus);
    };
  }, []);

  if (!box) return null;
  return (
    <div className="cms-overlay" style={{ top: box.top, left: box.left, width: box.width, height: box.height }} aria-hidden="false">
      <a className="cms-overlay__edit" href={box.href} target="_blank" rel="noopener">
        Edit {box.label}
      </a>
    </div>
  );
}

Components use the helper where they render CMS fields, for example <p {...editAttrs({ source: "directus", collection: "pages", id: page.id, field: "intro" }, preview)}>{page.intro}</p>. The overlay itself is a positioned outline with a real link inside, so editors can reach it with the keyboard: focusing an annotated element with Tab shows the outline and the edit link becomes the next focusable element. Style the outline with pointer-events: none on the box and pointer-events: auto on the link, so the overlay never blocks interaction with the page.

Making the Overlay Accessible

Editors use keyboards and assistive technology too, and an overlay that only reacts to the mouse locks them out. Three details make it usable. Trigger the outline on focusin as well as pointer events, as the component does, so tabbing through the page reveals edit targets. Put the edit action in a real link with a descriptive name, “Edit pages · intro” rather than a pencil icon, so screen readers announce where it goes. And never move focus when the outline appears: the overlay follows the editor’s focus, it does not take it. Test with a screen reader once: tabbing to an annotated heading should announce the heading, and the next Tab should announce the edit link for it.

Colour matters as well. The outline sits on top of arbitrary content, including photos and brand colours, so a single colour will vanish somewhere. Use a two-tone outline, such as a dark inner line and a light outer halo, which stays visible on any background, and keep the label on a solid background with sufficient contrast in both light and dark themes.

Configuration Reference

Item Value Why
Attributes data-cms-source, data-cms-entry, data-cms-field Enough to build any deep link and a readable label.
Helper behaviour empty object outside preview Published HTML stays clean.
Overlay loading dynamic import in preview layout only Zero cost on public pages.
Link target new tab, or postMessage when embedded Editors keep their place in the preview.
Outline styling pointer-events: none except on the link The page stays fully interactive.

Gotchas & Edge Cases

  • Nested annotations. A card inside a section inside a page can all be annotated. closest() picks the innermost, which is usually right; add a modifier key to walk up to the parent for editors who want the container.
  • Repeated items and list indexes. Field paths with indexes, such as blocks.2.caption, break when editors reorder blocks. Prefer stable block ids in the path where the CMS provides them.
  • Content from non-CMS sources. Hard-coded text should not be annotated at all, so editors are never sent to a place where the text cannot be changed.
  • Security of deep links. Deep links reveal collection names and ids, which is harmless for authenticated editors in preview, and another reason the attributes must never appear on published pages.
  • Scroll and resize. Recompute the outline on scroll and resize, or it drifts away from its element. The requestAnimationFrame wrapper keeps that cheap.

Worked Example

For the university site, the agency annotated about forty components in two days, most with a single line each, and added the course system as a second source. Editors could now click a course title to open it in the student information system and click an introduction paragraph to open the Directus page item. Support requests of the form “where do I change this text?” dropped to almost none within a month, and new editors needed far less onboarding, because the preview itself showed them where everything lived.

Weekly "where do I edit this?" requestsSupport requests from editors asking where a piece of content is edited, per week, before and after the click-to-edit overlay shipped.Before overlay14 requestsAfter overlay2 requests
Four weeks before and four weeks after launch, averaged; the remaining requests concerned hard-coded content.

Rollout Checklist

  • Add the editAttrs helper and a preview flag that reaches every component.
  • Annotate the components editors touch most, starting with page intros, heroes and cards.
  • Load the overlay script only in preview layouts, through a dynamic import.
  • Make the edit link keyboard reachable and label it with the collection and field.
  • Assert in a test that published HTML contains no data-cms- attributes.

Frequently Asked Questions

Can the overlay update content live as well?

The overlay handles navigation only. Combine it with a polling or streaming preview, as described in live editing integration patterns, so edits made after the click appear in the preview automatically.

How does this relate to Sanity’s stega or Contentful’s inspector mode?

They solve the same mapping problem with platform-specific transports: stega encodes the mapping into strings, and Contentful’s helpers produce data attributes much like the helper here. Use the platform feature when you have one, and this pattern when you do not.

Can the same attributes drive other preview tools?

Yes. Once elements carry entry and field ids, the preview can also show a panel of “content on this page” with links to every entry, highlight entries with unpublished changes, or list untranslated fields for the current locale. The annotations are a small investment that several editor tools can share.

Is it worth building for a small site?

For a handful of pages and one editor, probably not. It pays off when many editors work across many content types, or when content comes from more than one system and nobody remembers where each piece lives.