The visual editor opens your site inside Manifolio with the drafts in place. Editors click a heading and type, pick a new photo from the media library, or jump to a field in the side panel. Design stays in your code: the editor changes content, never layout or styles.
It takes three things on the site: read drafts when the page carries a preview token, mark the elements that show a field, and load the bridge script in preview.
How it works
- In the admin, Visual editor (in an entry or in the menu) asks the API for a preview token: signed, valid for 30 minutes, scoped to one project and able only to read drafts.
- The admin opens your preview URL in an iframe with
?mf_preview=<token>added. - Your server sees the token, reads drafts through the Preview API and renders the page as usual, with
data-mf-editattributes and the bridge script. - The bridge talks only to the Manifolio admin that framed the page. Edits go back to the admin, which saves them as drafts (autosave, conflicts and versions work as in the entry editor). Publishing stays a deliberate click.
1. Read drafts with the token
import { createClient, previewTokenFrom } from "@manifolio/sdk";
export function cmsFor(request: Request) {
const token = previewTokenFrom(request.url); // null on normal visits
return token
? createClient({ project: PROJECT_ID, preview: token }) // drafts, no key needed
: createClient({ project: PROJECT_ID, key: MANIFOLIO_KEY }); // published content
}Preview responses must not be cached. Skip your CDN or data cache whenever the token is present and send Cache-Control: private, no-store.
The token cannot publish, cannot write and cannot read another project. When it expires, the admin sends the bridge a fresh one, so navigation inside the editor keeps working.
2. Mark editable elements
---
import { editable } from "@manifolio/sdk";
const page = await cms.entry("page", "about");
---
<h1 {...editable(page, "title")}>{page.title}</h1>
<p {...editable(page, "lead")}>{page.lead}</p>
{page.sections.map((s, i) => (
<h2 {...editable(page, ["sections", i, "title"])}>{s.title}</h2>
))}
<img {...editable(page, "hero")} src={imageUrl(page.hero, { w: 1600 })} alt={page.hero.alt} />editable(entry, field, locale?) returns one attribute, data-mf-edit, with the entry type and id, the field path and the language. Lists and groups take a path: ["sections", 2, "items", 0, "title"] or "sections.2.items.0.title". The attribute is harmless on a public page; to keep public HTML clean, use client.editable(...), which returns nothing unless the client was created with a preview token.
What a click does depends on the field:
| Field | In the page |
|---|---|
| Text, slug, URL, email | edited in place; Enter saves, Esc cancels |
| Long text | edited in place; ⌘ Enter saves, Enter adds a line |
| Long text rendered as Markdown, formatted text | the panel opens on that field |
| Media | the media library opens; the picked file replaces the old one |
| Anything else | the panel opens on that field |
Typing in the panel updates text on the page instantly. After a save, photos and formatted text refresh in place; adding or reordering list items reloads the page and keeps the scroll position.
Sections (blocks)
A blocks field keeps the sections of a page; each block has _type and a stable _key. Mark the whole section with the block itself in the path, and the fields inside it the same way:
{page.sections.map((b) => (
<section {...editable(page, ["sections", b])}>
<h2 {...editable(page, ["sections", b, "title"])}>{b.title}</h2>
</section>
))}The section gets data-mf-block: hovering it in the editor outlines it and shows a bar to move it up or down, duplicate or delete it, and a + above and below that opens the section menu in the panel. The path uses the block key (sections[a1b2c3d4e5].title), so it survives reordering.
3. Load the bridge in preview
<script defer src="https://api.manifolio.com/v1/bridge.js" data-admin="https://app.manifolio.com"></script>Add it only when the token is present. The script is under 4 KB gzipped, has no dependencies and does nothing when the page is not inside the Manifolio admin: it checks the parent window and the admin origin on every message, in both directions. It uses no eval, injects no <style> and works under a strict Content Security Policy. With npm, import { startBridge } from "@manifolio/sdk/bridge" does the same.
Preview URL
Set it in Settings → Visual editor. Manifolio fills in {slug}, {type} and {locale} for the entry being edited:
https://example.com/{locale}/{slug}
https://example.com/preview?type={type}&slug={slug}&locale={locale}The second form points at a small route on your site that redirects each type to its page, keeping mf_preview. Redirects must stay on the same origin. The address must use https; http works only on localhost.
Framing and headers
Browsers block framing when a site sends X-Frame-Options or a frame-ancestors rule that leaves the admin out. On preview responses, allow the admin and nothing else:
Content-Security-Policy: frame-ancestors 'self' https://app.manifolio.com
Cache-Control: private, no-store
Referrer-Policy: same-originRemove X-Frame-Options on those responses, or keep it on normal pages only. Analytics scripts can stay off in preview so editor visits are not counted.
Limits in this version
- Formatted text is edited in the panel, not inside the page.
- Sections change through the editor’s autosave; the page reloads after the save to show the new order.
- Phones show a “use a larger screen” message: the editor needs the page and the panel side by side.