Architecture
A live preview that cannot drift
Most admin panels rebuild each section so editors can see their changes, which guarantees the preview and the real site diverge. Framing the actual site removes the duplicate entirely. The details that make it work, including the ones I got wrong first.
An editor changes the headline on a section and wants to see what it will look like. The obvious way to give them that is to render the section inside the admin panel: pull in the same fonts, approximate the layout, show the text at roughly the right size. It works on the first day.
It works on the first day because on the first day the two implementations are identical. Then a designer adjusts the spacing on the real site. Then somebody adds a gradient overlay. Then the section gets a new sub-heading that only the real component knows about. Nobody updates the admin copy, because the admin copy is not what visitors see and nothing breaks when it falls behind.
Six months later, editors are approving something that is not what ships. That is not a risk, it is a schedule. Two implementations of the same thing will drift, and the one nobody looks at drifts first.
Show them the thing, not a model of the thing
The alternative is to stop having two implementations. The website gets a dedicated preview route that renders the real component tree with real content, and the admin panel frames that route in an iframe. There is one implementation of every section, so there is nothing to keep in sync.
Everything after this is making that route behave itself, and none of it is complicated. It is just a list of things that each cost an afternoon when you discover them the hard way.
Keep the preview out of search
The preview route serves the same content as a real page, at a different URL. Left alone, that is duplicate content competing with the page it is previewing. It gets one line:
// Internal live-preview surface for the admin editor, never indexed.
export const metadata: Metadata = {
robots: { index: false, follow: false },
}Worth saying out loud because it is easy to skip: a preview surface is a public URL on your production domain unless you have deliberately made it otherwise. Treat it as one.
Hide the chrome, and scope the hiding carefully
The editor is looking at one section, so the site's own header and footer are noise. Hiding them is two selectors. The interesting part is which two.
body > header, body > footer { display: none !important; }
main { padding: 0 !important; }Write that as a plain header, footer selector and it works perfectly until somebody opens the footer editor, at which point the preview goes blank. The footer editor renders a footer inside the preview, and a rule that hides every footer on the page hides the very thing being previewed.
Scoping to the body's direct children is what separates the layout's chrome from the content under test. It is a one character fix and completely invisible until the day it bites.
Switch analytics off inside the frame
This one costs nothing to prevent and is genuinely hard to undo.
<script
dangerouslySetInnerHTML={{
__html: `window["ga-disable-G-XXXXXXXXXX"] = true;`,
}}
/>An editor working through a content update loads the preview dozens of times an hour, from an office, on desktop, bouncing immediately. Multiply by a team and a few months and the site's traffic data quietly becomes fiction: inflated pageviews, a bounce rate that means nothing, a device split skewed toward whatever the staff use. Nobody notices until somebody makes a decision on those numbers.
Any internal surface that renders your real pages needs analytics explicitly disabled. Preview panes, screenshot jobs, uptime checks, end to end tests. The pollution is invisible, retroactive, and cannot be cleaned out of historical data.
Pushing edits into the frame, and the race nobody sees locally
Live means live: the preview updates as the editor types, not when they save. The admin posts the draft into the frame on every change.
const postDraft = useCallback((data: Record<string, string>) => {
iframeRef.current?.contentWindow?.postMessage(
{ type: "preview:update", data },
WEB_URL, // a real origin, not "*"
)
}, [])
// Push edits to the live preview as they happen.
useEffect(() => { postDraft(values) }, [values, postDraft])On the receiving side the preview merges the draft over the content it was server rendered with, so unedited fields keep their real values and only the edited ones change:
const merged: SiteContentMap = { ...baseContent, ...overrides }Now the part that will bite you in production and never locally. The iframe takes time to load. Every edit made before it finishes is posted into a window that is not listening yet, and those messages are simply lost. On a fast local machine the frame is ready before the editor's first keystroke, so the bug does not exist. On a slower connection the editor types a headline, watches nothing happen, and reports that the preview is broken.
The fix is a handshake in the other direction. The preview announces itself when it mounts, and the admin responds by sending the current draft immediately:
// In the preview, on mount:
window.parent?.postMessage({ type: "preview:ready", section }, "*")
// In the admin: when the frame says it is ready, send what we have now.The general shape is worth remembering. Any time you push state into something that loads asynchronously, you need the loaded thing to ask for the current state rather than relying on having caught every update since the beginning.
Where this implementation is weak
The sender does the right thing and posts to a specific origin rather than a wildcard, so the draft cannot leak to a page that swapped itself into the frame. The receiver does not do the matching check:
function onMessage(e: MessageEvent) {
const data = e.data as { type?: string; data?: Record<string, unknown> }
if (data?.type === "preview:update" && data.data && typeof data.data === "object") {
setOverrides(data.data) // e.origin is never checked
}
}Any page that can get a handle to this window can push content into the preview. The impact is small: the route is noindex, it renders content rather than executing it, and the only person who ever looks at it is an editor with the admin already open. But it is one line to close, and the reason to close it is that the reasoning which makes it harmless today is exactly the kind of reasoning that stops being true after a refactor.
if (e.origin !== ADMIN_ORIGIN) returnViewport toggles, and the full view that fights the dialog
Editors need to judge a section at real sizes, so the pane carries a desktop and mobile toggle. Mobile is a fixed 390 pixel wide frame rather than a scaled screenshot, so what they see is the real breakpoint rendering at a real width.
Full view is where it got fiddly. The pane usually lives inside a modal, and portalling it over the whole viewport puts it inside a dialog that believes any click outside its content means dismiss. The toolbar buttons stop working, and the Escape key closes the modal instead of leaving full view.
// Escape leaves full view before the dialog can act on it.
window.addEventListener("keydown", onKey, true) // capture phase
// ...
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
// And pointer events are stopped on the full view layer, so the dialog's
// dismissable layer cannot swallow the toolbar clicks.
onPointerDown={fullView ? stopDialogDismiss : undefined}Nothing profound, but it is the kind of thing that turns a two hour job into a day. When you portal UI out of a dialog you inherit that dialog's event handling, and you have to opt out of it deliberately in both directions: pointer events and the keyboard.
The same pane, doing email
The strongest evidence that this approach is right is that it generalised for free.
The email builder needs a preview too, and it has the identical failure mode: rebuild the template in React so marketers can see it, and what they approve is not what lands in the inbox. So the pane accepts raw HTML instead of a URL, and frames the exact markup the API will send.
// An email is inert HTML, so the strictest sandbox renders it faithfully and
// also neutralises anything a template field smuggled in.
// No allow-scripts, no allow-same-origin.
{...(srcDoc !== undefined ? { srcDoc, sandbox: "" } : { src })}
// color-scheme on the frame is what the embedded document resolves its own
// prefers-color-scheme against, so this drives the email's dark mode rules
// without touching the admin's theme.
style={showScheme ? { colorScheme: scheme } : undefined}Two details there are worth stealing. An empty sandbox attribute is the most restrictive setting, not the least, and for content assembled from user-editable template fields it is both the faithful and the safe choice. And setting color-scheme on the iframe element is how you drive the framed document's own dark mode rules, which matters because email clients follow the reader's theme and a template that looks fine in light mode can be unreadable in dark.
What to take from this
- Never build a second implementation of something to show people what the first one looks like. Frame the real thing.
- Mark the preview route noindex. It is a public URL on your production domain.
- Scope chrome-hiding CSS to the layout's direct children, or the footer editor previews a blank page.
- Disable analytics inside the frame. Preview traffic pollutes your data invisibly and permanently.
- Have the frame announce when it is ready and send it the current state then, rather than assuming it caught every update.
- Post to a specific origin, and validate the origin on the receiving end too. Half the pair is not the pair.
- Portalling over a dialog means inheriting its event handling. Opt out on pointer events and on the keyboard.
- Use an empty sandbox for inert HTML. It is the strictest setting and the most faithful one.
The principle underneath all of it is boring and keeps paying: when you need to show somebody what something will look like, show them the thing. Every layer of approximation between the editor and reality is a place for the two to disagree, and they always eventually do. The wider platform, including the content model this edits and the dynamic hero it feeds, is written up in the Synerggy Events case study.