Focus:CMS

Theme integration guide

A Next.js theme is compatible with the full-site Focus:CMS editor if it follows seven rules. Wire those up and every text, image, link, and section on every page becomes editable in ?edit=1 mode (or once a valid editor session exists) — without any per-page plumbing.

The reference implementation is style-aesthetics — a Squarespace-cloned medspa site that ships in production with the editor wired in. Open any page with an editor session and you'll see the same toolbar, popovers, and overlays this guide describes.


Rule 1 — Reserve the data-focus-cms-* attribute namespace

The editor uses these data attributes to identify editable atoms and editor UI surfaces. Themes must:

Reserved attributes:

| Attribute | Owner | Meaning | |---|---|---| | data-focus-cms-path | annotator + manual wrappers | JSON pointer this node maps to | | data-focus-cms-section | annotator | Marks a top-level section for section-toolbar actions | | data-focus-cms-grid | annotator | Marks a fluid-engine grid container | | data-focus-cms-block | annotator | Marks a fluid-engine block (free-form grid cell) | | data-focus-cms-text | annotator | Marks a contenteditable text atom | | data-focus-cms-image | annotator | Marks an editable <img> | | data-focus-cms-link | annotator | Marks an editable <a> | | data-focus-cms-editable-root | EditableRawHtml | The host wrapper | | data-focus-editor-ui | toolbar + modals | Editor UI subtree (clicks here never clear selection) |

Rule 2 — Mount FocusCMSProvider near the root

// app/layout.tsx
import { FocusCmsShell } from "@/components/focus-cms-shell";
import { getFocusCmsConfig } from "@/lib/focus-cms-config";

export default async function Layout({ children }) {
  return (
    <html>
      <body>
        <FocusCmsShell {...getFocusCmsConfig()}>
          {children}
        </FocusCmsShell>
      </body>
    </html>
  );
}

When using the Supabase adapter, the key should be derived from the current pathname. Themes built for multi-page sites should use resolveContentKeyFromPath() (or an equivalent):

The shell must pass requireEditQuery={false} to FocusCMSProvider so the editor activates on session presence alone (no ?edit=1 slug in the URL).

Rule 3 — Render structured fields through RichEditableText and EditableImage

When the theme owns the React tree for a section (typical for the home page, structured marketing blocks, providers, etc.), wrap each editable field:

import { RichEditableText, EditableImage } from "focus-cms";

export function Hero({ block, path }) {
  return (
    <section>
      <RichEditableText path={`${path}.heading`} as="h1">
        {block.heading}
      </RichEditableText>
      <EditableImage path={`${path}.imageUrl`} src={block.imageUrl} alt="" />
    </section>
  );
}

Display mode falls back to plain text/img automatically — zero visual diff.

Rule 4 — Render raw HTML pages through EditableRawHtml

For ported/legacy/Squarespace-style routes whose content is a free-form HTML blob:

import { EditableRawHtml } from "@/components/editable-raw-html";

export function RawPage({ page }) {
  return (
    <EditableRawHtml
      html={page.bodyHtml}
      htmlPath="blocks/0/html"
      className="clone-shell"
    />
  );
}

EditableRawHtml handles:

The htmlPath argument is the JSON pointer of the field inside the page document. Defaults to blocks/0/html.

Rule 5 — Don't mutate data-focus-cms-path outside the editor

Once the annotator decorates the live DOM in edit mode, third-party scripts and theme code must not remove or rewrite the attributes. If your theme runs DOM-mutation passes (scroll-reveal classes, lazy-load observers), use safelist selectors that skip [data-focus-cms-path] or operate before the annotator runs.

Rule 6 — Provide a PageRecord shape compatible with the adapter

Themes that consume Focus:CMS must expose a per-page record shaped like:

type PageRecord = {
  pathname: string;
  title: string;
  description: string;
  bodyClass?: string;
  // Either:
  bodyHtml?: string;          // raw-HTML pages
  // ... or:
  blocks?: AnyBlock[];        // structured pages (home)
};

Store either shape under pages/{slug} or home/index in focus_cms_content. See lib/site-data.server.ts in the reference theme.

Rule 7 — Skip annotation inside non-text containers

The annotator already skips <script>, <style>, <iframe>, <svg>, <noscript>, <template>, <canvas>, <video>, <audio>, <object>, <embed>, and <map>. Themes must not place user-editable content inside any of these (or it won't be reachable from the editor).


Cross-key edits: nav, footer, site meta

Page content is only one of several editable surfaces. Navigation (nav/primary, nav/mobile), footer (footer/main), site meta (site/meta), and contact info (site/contact) all live in their own Supabase rows. Edits to those rows accumulate in a sibling cross-key store; the bottom-left toolbar reads from both stores and routes Save to the right adapter for each key.

When building a new editor for a non-page surface:

import { useCrossKeyStore } from "@/lib/cross-key-store";

const pushPatch = useCrossKeyStore((s) => s.pushPatch);
pushPatch(
  "nav/primary",
  { op: "add", path: "/items", value: nextItems },
  { coalesceKey: "items" }, // optional — collapses repeated edits
);

The toolbar will show the pending count, and clicking Save will mint an adapter via createAdapterForKey("nav/primary") and apply.

Optional: section toolbar

When a [data-focus-cms-section='1'] element is the active editor selection, the toolbar shows section-level actions (move-up / move-down / duplicate / delete / edit-raw-html). To opt a non-<section> container into this behaviour, add the data-clone-section attribute or the class sa-section. To exclude a specific <section>, remove the data-focus-cms-section attribute on mount.

Optional: page metadata panel

The Settings panel (toolbar > gear icon) edits companyName, website, salesEmail, contactEmail, phone, address, social handles, and logo variants for the current site. Drop the same fields into any section as draggable Components — the Footer especially benefits from binding {contact.phone} once and updating it everywhere from the Settings panel.


Verifying your theme

Two scripts ship in the reference theme and are good prior art for any new integration:

Both are public and theme-agnostic. Copy them into a new theme as a starting point.