Building Embeddable Widgets for SaaS: iframes, Web Components, and Cross-Origin Communication Patterns
A practical guide to the three main approaches for building embeddable SaaS widgets: iframes, Web Components with Shadow DOM, and script-injected React. Covers security models, cross-origin auth, responsive sizing, versioning, and performance budgets.
You build a SaaS product. At some point, a customer asks: “Can I embed this in my website?” Maybe it is a payment form, a feedback collector, a booking widget, or a live chat component. The feature request seems small. The implementation is not.
The moment your widget runs inside a third-party page, you are dealing with a fundamentally different security and execution environment. You do not control the host page’s CSP. You do not know what JavaScript libraries are already loaded. You cannot predict the host page’s CSS specificity. Your authentication tokens cannot travel as cookies across origins. Your widget’s network requests will be blocked unless CORS is configured correctly. And your widget must not measurably slow down the page it is loaded on.
This article covers the three main embedding patterns (iframes, Web Components, script-injected React), the security model behind each, how to handle authentication across origins, responsive sizing and theming, and deployment strategies that do not break existing embeds when you ship.
Choosing the Right Approach
There is no universal answer. Each pattern trades isolation for flexibility.
| Approach | DOM isolation | CSS isolation | JS isolation | Auth model | Complexity |
|---|---|---|---|---|---|
| iframe | Full | Full | Full | Token in URL / postMessage | Low (host), Medium (bridge) |
| Web Components (Shadow DOM) | Partial | Full (Shadow DOM) | None | Token attribute or query param | Medium |
| Script-injected React | None | None | Shared globals risk | Token in init config | High |
| Script + iframe bridge | Full | Full | Full | postMessage relay | High |
The iframe is the safest default. It is the right choice for payment forms, authentication flows, and anything that handles sensitive data. The Web Components pattern is better when you need tight visual integration and the data being handled is not sensitive. Script-injected React is a last resort for highly customized embeds where the customer needs to compose your components with their own.
iframes: Full Isolation with a Communication Bridge
An iframe loads your widget in a separate browsing context. The host page and the widget cannot directly access each other’s DOM, cookies, or JavaScript scope. This is the security model you want for a payment widget.
The tradeoff: you must explicitly bridge any communication between host and widget using postMessage.
The postMessage Protocol
Every postMessage call must be validated on both ends. On the widget side, validate the origin of incoming messages. On the host side, validate that messages come from your iframe’s origin.
// widget/src/bridge.ts
type HostMessage =
| { type: "INIT"; payload: { token: string; theme: "light" | "dark" } }
| { type: "RESIZE_REQUEST" }
| { type: "CLOSE" };
type WidgetMessage =
| { type: "READY" }
| { type: "HEIGHT_CHANGE"; payload: { height: number } }
| { type: "PAYMENT_SUCCESS"; payload: { transactionId: string } }
| { type: "PAYMENT_ERROR"; payload: { code: string; message: string } };
const ALLOWED_ORIGINS = new Set([
"https://app.yourproduct.com",
"https://yourproduct.com",
]);
export function initWidgetBridge(
onInit: (payload: HostMessage & { type: "INIT" })=> void
): void {
window.addEventListener("message", (event: MessageEvent) => {
// Never process messages from unknown origins.
if (!ALLOWED_ORIGINS.has(event.origin)) return;
const msg = event.data as HostMessage;
if (msg.type === "INIT") {
onInit(msg);
}
});
// Signal that the widget is ready to receive configuration.
window.parent.postMessage({ type: "READY" } satisfies WidgetMessage, "*");
}
export function sendToHost(msg: WidgetMessage): void {
// Use "*" only if you cannot know the parent origin at runtime.
// Prefer passing the parent origin explicitly when possible.
window.parent.postMessage(msg, "*");
}
On the host page side, a thin loader handles iframe lifecycle:
// host/src/widget-loader.ts
type WidgetMessage =
| { type: "READY" }
| { type: "HEIGHT_CHANGE"; payload: { height: number } }
| { type: "PAYMENT_SUCCESS"; payload: { transactionId: string } }
| { type: "PAYMENT_ERROR"; payload: { code: string; message: string } };
interface WidgetConfig {
container: HTMLElement;
token: string;
theme?: "light" | "dark";
onSuccess: (transactionId: string) => void;
onError: (code: string, message: string) => void;
}
export function mountPaymentWidget(config: WidgetConfig): () => void {
const iframe = document.createElement("iframe");
iframe.src = "https://widget.yourproduct.com/payment";
iframe.style.border = "none";
iframe.style.width = "100%";
iframe.style.height = "200px"; // Initial height before READY
iframe.setAttribute("allow", "payment");
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
const WIDGET_ORIGIN = "https://widget.yourproduct.com";
const handler = (event: MessageEvent) => {
if (event.origin !== WIDGET_ORIGIN) return;
const msg = event.data as WidgetMessage;
switch (msg.type) {
case "READY":
// Widget is mounted; send configuration.
iframe.contentWindow?.postMessage(
{ type: "INIT", payload: { token: config.token, theme: config.theme ?? "light" } },
WIDGET_ORIGIN
);
break;
case "HEIGHT_CHANGE":
iframe.style.height = `${msg.payload.height}px`;
break;
case "PAYMENT_SUCCESS":
config.onSuccess(msg.payload.transactionId);
break;
case "PAYMENT_ERROR":
config.onError(msg.payload.code, msg.payload.message);
break;
}
};
window.addEventListener("message", handler);
config.container.appendChild(iframe);
// Return a cleanup function.
return () => {
window.removeEventListener("message", handler);
config.container.removeChild(iframe);
};
}
The sandbox Attribute
Always set sandbox on the iframe. The minimum permissions for a typical widget are allow-scripts allow-same-origin allow-forms. Omit allow-same-origin only if you do not need cookies or localStorage in the widget (rare). Never add allow-top-navigation unless you have a clear reason; it lets the widget redirect the host page.
Responsive Height
iframes do not auto-resize. The widget must measure its own content and notify the host. A ResizeObserver on the widget’s root element is reliable:
// widget/src/resize-reporter.ts
import { sendToHost } from "./bridge";
export function watchHeight(rootEl: HTMLElement): void {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = Math.ceil(entry.contentRect.height);
sendToHost({ type: "HEIGHT_CHANGE", payload: { height } });
}
});
observer.observe(rootEl);
}
Web Components: Shadow DOM Isolation Without an iframe
Web Components give you CSS isolation through Shadow DOM and a clean API through custom element attributes. The widget runs in the host page’s JavaScript context, which means the host can call your element’s methods and read its properties. This is a benefit or a risk depending on your use case.
The right use case: a feedback widget, a support chat launcher, a notification bell. Anything where the widget’s data is not sensitive, but visual integration with the host page’s design system matters.
// widget/src/FeedbackWidget.ts
interface FeedbackWidgetConfig {
apiKey: string;
projectId: string;
theme?: "light" | "dark";
position?: "bottom-right" | "bottom-left";
}
class FeedbackWidget extends HTMLElement {
private shadow: ShadowRoot;
private config: FeedbackWidgetConfig | null = null;
static get observedAttributes(): string[] {
return ["api-key", "project-id", "theme", "position"];
}
constructor() {
super();
this.shadow = this.attachShadow({ mode: "closed" });
}
connectedCallback(): void {
this.config = {
apiKey: this.getAttribute("api-key") ?? "",
projectId: this.getAttribute("project-id") ?? "",
theme: (this.getAttribute("theme") as "light" | "dark") ?? "light",
position: (this.getAttribute("position") as "bottom-right" | "bottom-left") ?? "bottom-right",
};
this.render();
}
attributeChangedCallback(name: string, _old: string, next: string): void {
if (!this.config) return;
if (name === "theme") {
this.config.theme = next as "light" | "dark";
this.applyTheme(next);
}
}
private render(): void {
if (!this.config) return;
// Styles are scoped to the shadow root. They cannot leak out,
// and host page styles cannot leak in (with one exception: CSS custom
// properties / variables DO pierce the shadow boundary).
this.shadow.innerHTML = `
<style>
:host {
position: fixed;
${this.config.position === "bottom-right" ? "right: 24px;" : "left: 24px;"}
bottom: 24px;
z-index: 9999;
font-family: system-ui, sans-serif;
}
button {
background: var(--feedback-widget-primary, #6366f1);
color: white;
border: none;
border-radius: 8px;
padding: 10px 18px;
cursor: pointer;
font-size: 14px;
}
</style>
<button part="trigger">Feedback</button>
`;
this.shadow.querySelector("button")?.addEventListener("click", () => {
this.openPanel();
});
}
private applyTheme(theme: string): void {
// Update CSS custom property, which the shadow root can consume.
this.style.setProperty("--feedback-widget-primary", theme === "dark" ? "#818cf8" : "#6366f1");
}
private openPanel(): void {
// Dispatch a custom event that bubbles up through the shadow boundary.
this.dispatchEvent(
new CustomEvent("feedback-open", { bubbles: true, composed: true })
);
}
}
customElements.define("feedback-widget", FeedbackWidget);
Host page integration becomes a single HTML line:
<script src="https://widget.yourproduct.com/feedback.js" defer></script>
<feedback-widget api-key="pk_live_abc123" project-id="proj_xyz"></feedback-widget>
CSS Custom Properties Penetrate Shadow DOM
The host page can theme your widget by setting CSS custom properties on the element or a parent:
feedback-widget {
--feedback-widget-primary: #ec4899;
}
This is intentional: ::part() and CSS custom properties are the designed-in theming contracts for Web Components. Use them to expose safe theming surface without opening the full shadow to host styles.
Authentication Across Origins
Your widget needs to call your API. The API is at a different origin from the host page. Three patterns cover most cases.
Short-Lived Signed Tokens
The host page’s server mints a short-lived token (JWT or HMAC-signed string) that the widget uses directly. The widget never sees a long-lived API key. Token lifetime should be capped to the expected widget session: 15-60 minutes.
// your-api/src/widget-tokens.ts
import { SignJWT } from "jose";
interface WidgetTokenPayload {
customerId: string;
projectId: string;
permissions: string[];
widgetType: "payment" | "feedback" | "booking";
}
export async function mintWidgetToken(
payload: WidgetTokenPayload,
secret: Uint8Array
): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("30m")
.setAudience("widget")
.sign(secret);
}
The customer’s backend calls this endpoint and passes the resulting token to the embed snippet. The snippet passes it into the widget (via postMessage for iframes, via attribute for Web Components). The widget then uses it as a Bearer token for API calls.
CORS Configuration
Your widget’s origin must be on the allow-list. With short-lived tokens, you do not need credentials mode; simple CORS headers are sufficient:
// your-api/src/cors-middleware.ts
const ALLOWED_WIDGET_ORIGINS = [
"https://widget.yourproduct.com",
// If you want customers to self-host the widget script, this needs
// to be dynamic: validate against a customer-configured domain list.
];
export function widgetCorsHeaders(origin: string | null): Record<string, string> {
if (!origin || !ALLOWED_WIDGET_ORIGINS.includes(origin)) {
return {};
}
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Max-Age": "86400",
};
}
If a customer wants to embed the widget on any of their own domains, dynamically allow the widget origin by checking it against the customer’s registered domain list in your database.
Cookie Partitioning and Third-Party Cookies
Do not rely on cookies for widget authentication. Chrome’s third-party cookie deprecation, combined with Safari’s ITP, makes cookie-based auth unreliable in cross-origin embeds. This applies even to iframes: cookies set on widget.yourproduct.com will not be sent when the iframe is embedded on customer.com in most browsers.
Use token-in-memory instead: receive the token via postMessage or attribute, keep it in JavaScript memory inside the widget, attach it to API requests as a header. Never write it to localStorage or sessionStorage under your domain when running as an embed (those are shared across all tabs to your domain, not scoped to the embed session).
Versioning and Deployment
Widgets that load on someone else’s page have a different deployment contract than your main app. If you break the API and push a new version, you break every customer embed simultaneously. You have no rollback path unless you planned for it.
URL-Based Versioning
Host versioned bundles at predictable URLs:
https://widget.yourproduct.com/v2/payment.js (latest v2)
https://widget.yourproduct.com/v2.4.1/payment.js (pinned patch)
https://widget.yourproduct.com/latest/payment.js (always latest, opt-in)
The v2/ path receives all v2 minor and patch releases. The v2.4.1/ path is immutable. Customers who want stability pin to a minor; customers who want current behavior use the major track. Never mutate a pinned URL.
Backward Compatibility Contract
Your postMessage protocol, custom element attributes, and JavaScript API are your public API. Treat breaking changes the way you would treat a REST API breaking change: major version bump, deprecation notice, migration guide, minimum 90-day overlap period.
Within a major version, only add optional properties to message payloads. Never remove a message type. Never rename an attribute. If you need to change behavior, add a new attribute with a different name and keep the old one working.
Performance Budget
Your widget loads on someone else’s page. Their users did not ask for it. Every kilobyte you add to their page load is stolen from their content. Take this seriously.
Practical budgets:
| Resource | Hard limit | Notes |
|---|---|---|
| Initial JS bundle (gzipped) | 40 KB | Covers React or Preact + widget logic |
| CSS (gzipped) | 8 KB | Prefer inline critical CSS in the bundle |
| Web font | 0 KB | Use system-ui or let the host page’s font cascade |
| Third-party dependencies | 0 | Vendor everything; do not fetch from CDNs at runtime |
| First contentful paint budget | 200ms | Measured on a simulated 4G connection |
| API calls on init | 1 | Batch initial state into one request |
Preact (3 KB gzipped) is often a better runtime choice than React (45 KB) for widgets. If you need React because your main app is React and you want to share components, code-split aggressively and lazy-load anything not needed for the first render.
Load the widget script with async or defer. Never require the host page to load your script synchronously.
<!-- Correct: async load, non-blocking -->
<script
src="https://widget.yourproduct.com/v2/payment.js"
async
data-api-key="pk_live_abc123"
></script>
<!-- Wrong: blocks page parsing -->
<script src="https://widget.yourproduct.com/v2/payment.js"></script>
Lazy Initialization
For widgets that are not visible on first load (a feedback launcher, a chat bubble), defer all network activity until the user opens the widget. The script load plants a small stub that listens for user interaction; the full bundle loads on first interaction.
// widget/src/lazy-bootstrap.ts
function plantStub(): void {
const config = {
apiKey: document.currentScript?.getAttribute("data-api-key") ?? "",
projectId: document.currentScript?.getAttribute("data-project-id") ?? "",
};
// Mount a lightweight placeholder button.
const stub = document.createElement("div");
stub.id = "ys-feedback-stub";
stub.style.cssText = "position:fixed;bottom:24px;right:24px;z-index:9999;cursor:pointer;";
stub.textContent = "Feedback";
document.body.appendChild(stub);
stub.addEventListener(
"click",
() => {
// Remove stub, load the real widget on first interaction.
stub.remove();
import("./FeedbackWidget").then(({ FeedbackWidget }) => {
const el = document.createElement("feedback-widget") as FeedbackWidget;
el.setAttribute("api-key", config.apiKey);
el.setAttribute("project-id", config.projectId);
document.body.appendChild(el);
// Immediately open the panel since the user already clicked.
el.openPanel();
});
},
{ once: true }
);
}
plantStub();
Production Considerations
CSP on the host page. Customers with strict Content Security Policy headers will block your iframe or script unless they explicitly add your origin to their frame-src and script-src directives. Document the exact CSP additions required in your integration guide. Provide a CSP snippet customers can copy. If you inject inline styles from JavaScript, you also need style-src 'unsafe-inline' or a nonce-based approach.
Widget isolation testing. Test your widget embedded in a page with a conflicting CSS reset, an aggressive global * { box-sizing: content-box } override, and a noisy JavaScript environment that defines window.fetch as a no-op. You will discover isolation gaps before customers do.
Error boundaries in the widget. Your widget must never throw an uncaught error that propagates to the host page. A JavaScript error in your widget should surface as a graceful fallback state inside the widget’s DOM, not as a broken experience for the host page’s user.
Multi-tenant token validation. When your widget calls your API with a short-lived token, validate that the token’s customerId matches the customer that owns the projectId being requested. Tokens minted by one customer must not be usable to access another customer’s project.
Subresource integrity. If customers pin to a versioned URL, consider adding SRI (Subresource Integrity) support so security-conscious customers can verify your script has not changed. Publish SHA-256 hashes alongside each release.
Putting It Together
The iframe pattern is the right default for anything security-sensitive. Use the typed postMessage protocol shown above, validate origins strictly on both sides, and resist the temptation to simplify by setting targetOrigin to "*" on messages containing tokens or user data.
The Web Components pattern is the right choice when visual integration matters more than isolation. CSS custom properties give you a safe theming contract. The ::part() pseudo-element gives customers a surgical way to style specific internals without breaking everything else.
Both patterns require the same deployment discipline: versioned URLs, a stable postMessage or attribute API, and a performance budget that respects the page your widget loads on. The widget that loads in 80ms and occupies 30 KB will get embedded and stay embedded. The one that loads in 800ms and pulls in React plus a feature-flagging SDK plus an analytics library will not survive its first customer’s Core Web Vitals audit.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.