Search documentation

Search documentation

Visual Builder

Visual Editing

Use authenticated preview to edit drafts safely in the frontend.

What visual editing is#

Visual editing is the in-canvas editing layer used inside a protected preview page. It lets editors work in the real frontend layout instead of a separate approximation.

In a configured preview iframe, editors can:

  • select a block;
  • open the component field editor for component blocks;
  • insert blocks;
  • move, drag, duplicate, or delete placements;
  • see the preview refresh after a draft change.

The CMS owns mutations and permissions. The frontend preview supplies the rendered page, selection attributes, and a secure postMessage bridge.

Requirements#

Before enabling visual editing, make sure all of these are true:

  1. The website has a Preview URL containing [pageId].
  2. Your preview route fetches client.pages.getPreview(pageId) or getContoprixPreviewPage({ pageId }).
  3. Preview credentials with preview:read stay on the server.
  4. The preview route is protected and has no public cache.
  5. Your custom block components spread previewAttributes onto their outer element.
  6. The allowed CMS admin origin is configured exactly.

The CMS uses the template below and replaces the placeholder with an encoded page ID:

Website Preview URL
https://www.example.com/preview/page/[pageId]

1. Create a draft preview route#

A preview fetch uses page ID rather than public URL and returns draft content plus a versionId.

app/preview/page/[pageId]/page.tsx
import { getContoprixPreviewPage } from "@contoprix/next/server";

import { VisualPreviewCanvas } from "@/contoprix/VisualPreviewCanvas";

export const dynamic = "force-dynamic";

export default async function PreviewPage({
  params,
}: {
  params: Promise<{ pageId: string }>;
}) {
  const { pageId } = await params;

  const page = await getContoprixPreviewPage({
    pageId,
    languageCode: "en",
  });

  return <VisualPreviewCanvas initialPage={page} />;
}

Use a server-side API client that has preview:read. A delivery-key-only client is for published delivery and should not be used as a substitute for preview authorization.

2. Enable the VisualEditingBridge through PageRenderer#

PageRenderer installs the bridge whenever visualEditing.enabled is true and the preview page has a versionId.

src/contoprix/VisualPreviewCanvas.tsx
"use client";

import { useRouter } from "next/navigation";
import { PageRenderer } from "@contoprix/react/client";
import type { ContoprixPage } from "@contoprix/types";

import components from "./components";
import { schemas } from "./schema";

export function VisualPreviewCanvas({
  initialPage,
}: {
  initialPage: ContoprixPage;
}) {
  const router = useRouter();

  return (
    <PageRenderer
      page={initialPage}
      components={components}
      schemas={schemas}
      renderMode="editor"
      visualEditing={{
        enabled: Boolean(initialPage.versionId),
        adminOrigin: process.env.NEXT_PUBLIC_CONTOPRIX_ADMIN_ORIGIN!,
        onRefresh: () => router.refresh(),
        refreshPollingIntervalMs: 0,
      }}
    />
  );
}

adminOrigin is the CMS admin origin, not the API origin. It is public configuration, but it must be exact. For deployments with multiple allowed admin origins, use the trustedRefreshOrigins option and validate every origin before trusting it.

3. Preserve selection attributes in custom components#

The bridge finds a component's outer DOM element through previewAttributes. Without them, public rendering still works but the preview cannot select the block.

Correct root element
export default function Promo({
  settings,
  previewAttributes,
}: ContoprixComponentProps) {
  return (
    <section {...previewAttributes}>
      <h2>{String(settings?.heading ?? "")}</h2>
    </section>
  );
}

Do not attach the attributes to a hidden element or a tiny child. Put them on the visible block wrapper.

What the editor can change#

Component blocks are owned by the page draft, so their fields are editable in the inline Visual Builder editor.

Content-entry and form blocks are placements:

  • A content block can be moved, duplicated, or removed from the page, but its fields belong to the content entry's own editor.
  • A form block can be moved, duplicated, or removed, but its fields belong to the form builder.

This keeps one content entry or form definition reusable in multiple pages without creating conflicting copies.

Security rules#

  • Never expose client credentials or preview secrets with NEXT_PUBLIC_ variables.
  • Do not make a [pageId] URL public merely because the ID is hard to guess. Enforce your preview authorization.
  • Do not cache preview responses in a public CDN, application cache, or static build.
  • Trust refresh messages only from the configured admin origin and only from the parent iframe.
  • Treat URL-provided origin values as untrusted until checked against an allow-list.
  • Make preview visibly different from public delivery, for example with an editor-only banner.

Warning

Visual editing is not a public feature flag. It exposes draft data and must be reachable only through an authorized preview flow.

Troubleshooting#

SymptomCheck
Preview gets 401 or 403Server credentials have preview:read and the preview route authorizes the editor.
The iframe is blankThe Preview URL contains [pageId] and points at a valid frontend route.
Clicking a block does nothingThe component spreads previewAttributes and adminOrigin matches the embedding admin.
Changes do not appearonRefresh refetches/re-renders preview data and the preview route is dynamic/no-store.
A stale-version error appearsAnother editor changed the draft. Refresh the preview and retry.
A content entry has no inline fieldsExpected: edit the content entry in its own content editor.