We ship small embeddable widgets (a club availability view, a court-cost calculator, a data chart) that other sites can put on a page. This post is about the design decisions: how the iframe is locked down, how it resizes without hand-tuning, and how the credit line is built so the attribution is visible, optional to remove, and not a trick.

Two pieces: a loader and an embed route

The host page adds a script and a placeholder:

<script src="https://meetandplay.mx/embed.js" async></script> <div data-meetandplay-club="club-slug"></div> 

The script finds each placeholder, builds an iframe pointing at a dedicated /embed/... route on our domain, and inserts it. Options come from data-* attributes (data-locale, data-accent, data-bg, data-sport) and every one is re-validated in the loader and again on the server, since a data attribute is user input:

const HEX = /^(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; const LOCALE = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/; function buildSrc(host, slug) { const params = new URLSearchParams(); const accent = (host.getAttribute("data-accent") || "").replace("#", ""); if (HEX.test(accent)) params.set("accent", accent.toLowerCase()); const locale = host.getAttribute("data-locale") || "es-MX"; const safe = LOCALE.test(locale) ? locale : "es-MX"; return `${origin}/embed/${safe}/club/${encodeURIComponent(slug)}?${params}`; } 

Make only the embed routes frameable

The easy mistake is to relax framing for the whole site. Instead, everything except /embed/* gets X-Frame-Options: SAMEORIGIN and frame-ancestors 'self', and the embed group replaces the CSP with one that allows any ancestor. In Next.js that is two header rules:

export const EMBED_SOURCE = "/embed/:path*"; // every path that is NOT under /embed export const FRAME_GUARD_SOURCE = "/:path((?!embed(?:/|$)).*)"; 

X-Frame-Options has no "allow all" value, so you cannot override it away on the embed routes; they must be excluded from the group that sets it. Allowing any ancestor is only reasonable because the widget holds nothing worth clickjacking: it shows public data, has no login or payment form, and links open the full app in a new tab (target="_blank" with rel="noopener").

We also send X-Robots-Tag: noindex, nofollow on the embed routes. The framed page is a fragment of someone else's page, not a result in its own right.

Auto-resizing with postMessage

Hand-tuned iframe heights break the moment content changes. The embed page reports its height; the loader applies it:

// inside the embed page useEffect(() => { if (window.parent === window) return; const post = () => window.parent.postMessage( { type: "meetandplay:height", height: document.documentElement.scrollHeight }, "*" ); post(); const ro = new ResizeObserver(post); ro.observe(document.documentElement); return () => ro.disconnect(); }, []); 
// in the loader window.addEventListener("message", (event) => { if (event.origin !== origin) return; // only our origin if (event.data?.type !== "meetandplay:height") return; const h = Number(event.data.height); if (!Number.isFinite(h)) return; const clamped = Math.min(4000, Math.max(200, Math.ceil(h))); for (const frame of frames) if (frame.contentWindow === event.source) // only frames we created frame.style.height = `${clamped}px`; }); 

Two details: the target origin "*" on the sender is acceptable because the payload is one number about a public page, and the receiver checks both event.origin and that event.source is one of its own frames, then clamps the value so a spoofed message cannot make the host layout absurd.

The credit line

An embed that gives the host something useful can reasonably ask for a credit. Rules we set ourselves:

  • Visible. A normal caption under the widget, not hidden, not tiny, not off-screen.
  • Brand-only anchor text. The link text is the product name, not a keyword phrase. Keyword-stuffed anchors are exactly what makes embed credits look like link schemes.
  • A plain link. It is a normal link with a utm_source parameter, so the target site can see where visits come from. No cloaking, no redirects.
  • Where it lives. The snippet we give people includes the credit inside the placeholder div. The loader inserts the iframe above existing content, so the line reads as a caption:
// host.insertBefore(frame, host.firstChild) 
<div data-meetandplay-club="club-slug"> <p><a href="https://meetandplay.mx/?utm_source=embed">MeetAndPlay</a></p> </div> 

A side benefit of this design: with JavaScript disabled or the script blocked, the placeholder still renders a useful link rather than an empty box.

Things I would check before copying this

  • Don't put anything stateful in a frameable route. Login, payments, forms with side effects: keep them out and open the main app in a new tab.
  • Keep the loader dependency-free and wrapped in an IIFE so it leaks no globals onto the host page.
  • Clamp every number you accept from postMessage.
  • Be willing to be ignored. If someone removes the credit, the widget should still work. Attribution that has to be forced is not worth having.

The pattern is small enough to reuse for any read-only widget. If you want to see it in action, the club availability widget from MeetAndPlay uses exactly this loader.