Web Engineering ·

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.

Building Embeddable Widgets for SaaS: iframes, Web Components, and Cross-Origin Communication Patterns

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.

ApproachDOM isolationCSS isolationJS isolationAuth modelComplexity
iframeFullFullFullToken in URL / postMessageLow (host), Medium (bridge)
Web Components (Shadow DOM)PartialFull (Shadow DOM)NoneToken attribute or query paramMedium
Script-injected ReactNoneNoneShared globals riskToken in init configHigh
Script + iframe bridgeFullFullFullpostMessage relayHigh

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.

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:

ResourceHard limitNotes
Initial JS bundle (gzipped)40 KBCovers React or Preact + widget logic
CSS (gzipped)8 KBPrefer inline critical CSS in the bundle
Web font0 KBUse system-ui or let the host page’s font cascade
Third-party dependencies0Vendor everything; do not fetch from CDNs at runtime
First contentful paint budget200msMeasured on a simulated 4G connection
API calls on init1Batch 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
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
Web Engineering ·

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
Web Engineering ·

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
Web Engineering ·

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.