Implementing Live Preview in React with iframe Isolation
This guide implements the iframe pattern from Live Editing Integration Patterns. Wrapping a headless CMS preview in an <iframe> gives you a hard DOM/CSS boundary between the host editor and the rendered draft, killing style collisions, script interference, and routing conflicts. Done right, it also carries a bidirectional postMessage channel that syncs draft state without leaking auth material into the host or degrading host performance. The iframe is an architectural boundary, not a UI convenience.
How Isolation Works
The hard part is keeping the boundary strict while still syncing data in real time. The <iframe> sandboxes DOM and CSS, so preview stylesheets, hydration, and third-party tracking run in an isolated context. The only channel across it is window.postMessage, which demands strict origin validation, payload serialization, and deterministic state reconciliation.
This maps onto the broader Preview & Draft Workflow Patterns: draft tokens, incremental payloads, and environment flags cross a secured bridge without touching the host DOM. The host is a message router and state coordinator; the iframe is a passive consumer that hydrates React from incoming payloads.
Three layers:
- Transport.
postMessagewith an origin allowlist and JSON serialization. - State. A deterministic reducer inside the iframe that applies incremental updates without full reloads.
- Presentation. React components that subscribe to draft state and handle hydration mismatches on first load.
The bridge runs a strict, validated message exchange across the boundary:
Common Failures
Cross-origin breakdowns
postMessage fails silently when the iframe src resolves to a different origin than the host. event.origin validation must match protocol, domain, and port exactly. The usual trigger: preview URLs built with trailing slashes or query params that shift the resolved origin, so the host listener discards valid payloads. Not normalizing www prefixes or http vs https adds intermittent sync failures that are painful to trace in production.
Token leakage and state desync
Tokens in URL query strings leak into browser history, referrer headers, and server logs. When a token expires mid-session, the iframe keeps rendering stale draft data while the host assumes it’s still synced. Desync also shows up when the iframe’s React hydration finishes before the first draft payload arrives — you get a flash of published content or a hydration mismatch. It gets worse when developers try to bypass the bridge by writing tokens to localStorage/sessionStorage across origins, which browsers block.
Implementation
The Preview Bridge Hook
The message router handles payload validation, origin filtering, and listener cleanup. This hook is a type-safe bridge between the host app and the iframe, with strict origin validation, payload checks, and automatic listener teardown to avoid memory leaks.
import { useState, useEffect, useRef, useCallback } from 'react';
interface PreviewMessage<T = unknown> {
type: 'DRAFT_UPDATE' | 'TOKEN_REFRESH' | 'READY' | 'ERROR';
payload: T;
timestamp: number;
}
interface UsePreviewBridgeOptions {
allowedOrigin: string;
onReady?: () => void;
onError?: (error: string) => void;
}
export function usePreviewBridge<T = unknown>({
allowedOrigin,
onReady,
onError,
}: UsePreviewBridgeOptions) {
const [draftState, setDraftState] = useState<T | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const isMounted = useRef(true);
const validateOrigin = useCallback((origin: string) => {
const normalizedAllowed = allowedOrigin.replace(/\/+$/, '');
const normalizedEvent = origin.replace(/\/+$/, '');
return normalizedAllowed === normalizedEvent;
}, [allowedOrigin]);
useEffect(() => {
isMounted.current = true;
const handleMessage = (event: MessageEvent<PreviewMessage<T>>) => {
if (!validateOrigin(event.origin)) return;
if (!event.data || typeof event.data.type !== 'string') return;
switch (event.data.type) {
case 'READY':
onReady?.();
break;
case 'DRAFT_UPDATE':
if (isMounted.current) {
setDraftState(event.data.payload);
}
break;
case 'TOKEN_REFRESH':
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{ type: 'TOKEN_REFRESH', payload: event.data.payload, timestamp: Date.now() },
allowedOrigin
);
}
break;
case 'ERROR':
onError?.(String(event.data.payload));
break;
}
};
window.addEventListener('message', handleMessage);
return () => {
isMounted.current = false;
window.removeEventListener('message', handleMessage);
};
}, [allowedOrigin, onReady, onError, validateOrigin]);
const sendToIframe = useCallback((message: Omit<PreviewMessage<T>, 'timestamp'>) => {
if (!iframeRef.current?.contentWindow) return;
iframeRef.current.contentWindow.postMessage(
{ ...message, timestamp: Date.now() },
allowedOrigin
);
}, [allowedOrigin]);
return { draftState, iframeRef, sendToIframe };
}
Iframe Configuration and Sandbox Attributes
Set the sandbox attribute explicitly to restrict capabilities while allowing the scripts you need. React previews typically require allow-scripts and allow-same-origin for hydration and postMessage.
<iframe
ref={iframeRef}
src={previewUrl}
sandbox="allow-scripts allow-same-origin"
loading="lazy"
title="Content Preview"
className="preview-frame"
style={{ width: '100%', height: '100vh', border: 'none' }}
/>
Account for rapid keystrokes in editorial interfaces: debounce DRAFT_UPDATE transmission so you don’t congest the network or overwhelm the iframe’s React reconciler with micro-updates. This mirrors the broader Live Editing Integration Patterns, where incremental payloads are batched and applied during React’s idle periods via requestIdleCallback or setTimeout throttling.
Hardening and Performance
Content Security Policy
Set a CSP on the host that restricts frame-src to known preview domains, and frame-ancestors on the preview domain to block clickjacking and unauthorized embedding. Browsers enforce these natively, giving defense in depth that doesn’t depend on JavaScript validation.
Listener cleanup
React strict mode and concurrent rendering trigger repeated mount/unmount cycles in development. Leaked postMessage listeners mean duplicate handlers, memory leaks, and unpredictable state mutations. The usePreviewBridge hook above uses a mounted flag and explicit removeEventListener teardown to stay deterministic across HMR and route transitions.
Hydration race
To prevent a flash of published content on first load, gate rendering: keep the component tree hidden until the first DRAFT_UPDATE arrives and applies. A transparent overlay inside the iframe that fades out once React.hydrateRoot completes and the draft state reconciles does the job.
Configuration Reference
| Setting | Value | Notes |
|---|---|---|
allowedOrigin |
exact scheme, host and port | Normalize trailing slashes; never use "*" as targetOrigin. |
| Update debounce | 150 to 300 ms | Batches keystrokes without feeling laggy. |
sandbox |
allow-scripts allow-same-origin only when the preview is on another origin |
On the same origin, this pair lets the frame remove its own sandbox. |
| First-render gate | hide until first DRAFT_UPDATE or 1 s timeout |
Avoids a flash of published content without hanging forever. |
| Message schema | type, payload, timestamp, optional version |
Lets the iframe ignore out-of-order updates. |
The sandbox note matters. Combining allow-scripts with allow-same-origin on a frame served from the host’s own origin lets script inside the frame reach up and remove the sandbox attribute, which makes the sandbox decorative. Serve the preview from a separate origin, such as preview.example.com, when you rely on the sandbox for isolation, or drop allow-same-origin if the preview does not need cookies or storage.
Gotchas & Edge Cases
- Out-of-order messages. Debounced updates can arrive after a newer one if network work happens in between. Include a monotonically increasing version and ignore older payloads in the reducer.
- Large payloads. Sending the whole page’s draft on every keystroke is wasteful. Send the changed entry or field only, and let the reducer merge it.
- Iframe resizing. An iframe with a fixed
100vhheight gives scroll-within-scroll editing. Report the document height from the frame on resize and set the iframe height from the host. - Third-party scripts in preview. Analytics and chat widgets inside the preview record editors as visitors. Disable them in preview mode.
- Strict mode double effects. In development, effects run twice, which can open two message listeners if cleanup is wrong. The hook’s cleanup handles this; test with strict mode on.
Worked Example
An agency built a landing page editor on Strapi for a client whose design system used global CSS. The first version rendered previews inline in the Strapi admin, and the admin’s styles bled into the preview, making buttons look different from the live site, which led editors to file bugs about the design system. Moving the preview into an iframe on a separate preview subdomain, with the bridge hook above and a 200 ms debounce, gave pixel-identical rendering and cut editor-reported “styling bugs” to zero. A version counter in the payload later fixed an intermittent issue where a fast typist saw characters briefly disappear as older updates arrived after newer ones.
Frequently Asked Questions
Why not render the preview in the host with shadow DOM instead?
Shadow DOM isolates styles but not scripts, globals or routing, and many frameworks assume they own the document. An iframe isolates all of them, which makes it the more reliable boundary for rendering a full frontend application.
How do I support click-to-edit inside the iframe?
Have the frame post a FOCUS_FIELD message with the entry id and field path when an editor clicks an annotated element, and let the host move the CMS editor’s focus. The click-to-edit overlay guide covers the annotation side.
Can the iframe preview work across different CMS instances?
Yes, as long as each instance’s origin is in the allow-list and the preview route knows which instance a session belongs to. Keep the allow-list explicit rather than matching patterns.
Should the iframe reload when the editor switches entries?
Navigate the iframe to the new entry’s preview URL instead of reloading the host. The bridge re-establishes itself through the READY message, and the host keeps its own state, such as open panels and scroll positions in the editor.
How do I debug a bridge that stops syncing?
Log every received message with its origin and type in development, and compare the logged origin with allowedOrigin character by character. Most silent failures are an origin mismatch after a domain or protocol change, or a listener that was removed and never re-added after a route transition.