Embeds
Videos, posts, music, maps and design files from 33 providers, from links, iframe code or the sites' own embed code, plus sandboxed iframes from anywhere else.
Adding embeds#
- Paste a link on its own line. A provider URL alone on a line or in a pasted paragraph becomes an embed. Press ⌘Z / Ctrl+Z once to get the link back, or use Convert to link in the embed's bar. A URL inside a sentence stays a link, and links to other sites are never turned into frames.
- Paste iframe code from a site's "Embed" or "Share" dialog. Provider iframes are rebuilt from our own template; anything else becomes a sandboxed generic embed sized from its
width/height. - Paste a site's embed code, the
<blockquote>plus<script>that X, Instagram, Bluesky, Threads, Reddit and others give you. See Embed code. - Paste a page with embeds. Iframes and the sites' own markup become embeds; their scripts are dropped.
- Type
/embedand paste a link, embed code or iframe code into the box. This is the one place a link to an unknown site can become a (sandboxed) embed.
Every embed has a bar with the site, an editable caption, and an Open ↗ link. Many sites refuse to be framed, and a page can't detect that across origins, so the link is always there. Drag the right edge to resize; the width is stored as previewWidth and exported.
Iframes load on their own. Pass embeds.clickToLoad: true if you want a "Load … embed" button first, so a document full of videos doesn't talk to those sites until the reader asks.
Embed code#
Many sites' "Embed" button gives a <blockquote> (or a <div> or <a>) holding the post's text, plus a <script> that swaps it for the real thing. The editor reads that code with or without its script: in pasted HTML, pasted as text (the "Copy code" button), in value / setHTML, and in the /embed box. The pasted script is never run or kept. Only the post's URL and readable text are.
- The text stays. The blockquote's lines are stored on the block (
fallback). They show while the frame loads and when the policy refuses the embed. With theembedtool off, they become paragraphs above the link. - It exports back as embed code.
getHTML()writes the site's canonical snippet, rebuilt from the stored URL and text (never from the pasted markup), inside our figure. It reads as a quote anywhere, renders as the real post when published with the site's script, and reads back to the same block.
How each site is drawn
Wherever a site has a frame URL, we draw the post in our own sandboxed iframe, rebuilt from the post URL. The site's widget script is never loaded into your page. Only script-only embeds, which have no frame URL, run the site's official script, and only inside an opaque-origin srcdoc frame (sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox", no allow-same-origin).
| Site | Embed code read | Drawn with | Why |
|---|---|---|---|
| X / Twitter | blockquote.twitter-tweet | Our iframe | platform.twitter.com/embed/Tweet.html is the frame widgets.js itself loads. |
| blockquote.instagram-media | Our iframe | The /embed/ page is public and frameable. | |
| TikTok | blockquote.tiktok-embed | Our iframe | tiktok.com/embed/v2 is the player embed.js inserts. |
| Threads | blockquote.text-post-media | Our iframe | threads.net …/embed/ is frameable (threads.com sends X-Frame-Options: DENY). |
| Bluesky | blockquote.bluesky-embed | Our iframe | embed.bsky.app takes the post's DID URI. Handle-only URLs stay links: the frame needs a DID. |
| blockquote.reddit-embed-bq | Our iframe | embed.reddit.com allows any ancestor. Reddit may block automated browsers. | |
| div.fb-post, div.fb-video | Our iframe | The post.php / video.php plugins are the frames the SDK creates. | |
| a[data-pin-do=embedPin] | Our iframe | assets.pinterest.com/ext/embed.html is the frame pinit.js inserts. | |
| Tumblr | div.tumblr-post | Our iframe | embed.tumblr.com needs the snippet's key, so the embed URL is the stored URL. |
| Mastodon | blockquote.mastodon-embed | Our iframe | Every server serves /@user/id/embed. Listed servers are providers; others become sandboxed generic embeds. |
| Imgur | blockquote.imgur-embed-pub | Our iframe | imgur.com/…/embed is the frame embed.js inserts. |
| Flickr | a[data-flickr-embed] | Our iframe | The photo's /player/ page. The widget writes the image itself, so export is the iframe. |
| Telegram | script[data-telegram-post] | Our iframe | t.me/channel/id?embed=1 is the frame the widget script inserts. |
| GitHub Gist | script[src^=gist.github.com] | Sandboxed widget | Script-only: it writes the gist into the page and has no frame URL. |
Spotify, SoundCloud, YouTube, LinkedIn, Giphy and most others only give iframe code, which maps to the same providers. Instagram and TikTok frames ignore the embed's caption text; X frames show the live post.
Exporting embed code
editor.getHTML(); // default "auto": embed code back as embed code
// <figure data-genesis-embed="snippet" data-provider="twitter" data-url="https://twitter.com/jack/status/20" …>
// <blockquote class="twitter-tweet"><p>just setting up my twttr</p><p><a href="…">— jack (@jack) March 21, 2006</a></p></blockquote>
// </figure>
editor.getHTML({ embedScripts: true }); // + each site's official <script>, once, for publishing elsewhere
editor.getHTML({ embeds: "iframe" }); // our sandboxed iframes; the text goes in data-fallback
editor.getHTML({ embeds: "link" }); // email and RSS: the text as a quote, then the link
// The same options on toHTML(blocks, options) from "genesis-editor/parser".srcdoc frame, and that frame inherits your page's Content Security Policy. If you set one, add getEmbedScriptSrc() to script-src and allow the frame's one-line inline height reporter. Otherwise the frame keeps its default height.Embed code for your own providers
A provider can declare a snippet matcher. Its selector may match a blockquote, div, a or script, and url reads the page URL from it. That URL must still pass the provider's match, so a look-alike snippet pointing elsewhere is ignored.
const acme = defineEmbedProvider({
name: "acme",
label: "Acme Posts",
match: [/^https:\/\/acme\.social\/p\/(\d+)$/],
frameOrigins: ["https://embed.acme.social"],
toEmbed: (url, [, id]) => ({ src: `https://embed.acme.social/p/${id}`, height: 480 }),
snippet: {
selector: "blockquote.acme-post",
url: (el) => el.querySelector("a[href^='https://acme.social/p/']")?.getAttribute("href"),
// Optional: written back on export (escape anything you add; body is already escaped).
toHTML: (url, body) => `<blockquote class="acme-post">${body}</blockquote>`,
script: "https://acme.social/embed.js",
},
});Providers#
YouTube embeds use youtube-nocookie.com. Tweets use the plain iframe, not widgets.js. Sizes follow each site: 16:9 for video (9:16 for Shorts and TikTok), fixed heights for posts, players and forms.
Policy and security#
<BlockEditor embeds={{ policy: "open" }} /> // providers + sandboxed generic iframes (default)
<BlockEditor embeds={{ policy: "allowlist" }} /> // providers only; unknown iframes become links
<BlockEditor embeds={{ clickToLoad: true }} /> // wait for a click before loading the iframe
<BlockEditor embeds={{ providers: ["youtube", "vimeo", "loom"] }} />- Provider frames are rebuilt from the page URL every time they're drawn or exported, so a stored or pasted
srcis never trusted, and the result must load from the provider'sframeOrigins. They getsandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation". That includesallow-same-origin, which is safe only because the frame's origin is the provider's, not yours. - Generic frames must be
https:, can't be your app's own origin, and getsandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox": noallow-same-origin, forms or top navigation. They also get noallowfeatures,referrerpolicy="no-referrer", andcredentiallesswhere the browser supports it. - Pasted
sandbox,allow,styleandon*attributes are always dropped. Onlysrc,title,widthandheightare read.
"open" lets any author show any third-party page to readers, which is a way in for phishing look-alikes and trackers. Use "allowlist" for user-generated or multi-tenant content. htmlMode="strict" also allows providers only.Content Security Policy
getEmbedFrameSrc() lists the origins the enabled providers load from, plus https: when generic embeds are on.
import { getEmbedFrameSrc } from "genesis-editor";
// next.config.ts, middleware, or your server's headers
const frameSrc = getEmbedFrameSrc({ policy: "allowlist" }).join(" ");
// → "https://www.youtube-nocookie.com https://player.vimeo.com https://www.tiktok.com …"
headers.set("Content-Security-Policy", `frame-src ${frameSrc}`);Custom providers#
import { BlockEditor, defineEmbedProvider } from "genesis-editor";
const acme = defineEmbedProvider({
name: "acme",
label: "Acme Video",
// Anchored page-URL patterns; capture groups go to toEmbed.
match: [/^https:\/\/video\.acme\.com\/v\/(\d+)$/],
// The only origins toEmbed may return, and the CSP frame-src entries.
frameOrigins: ["https://player.acme.com"],
// Pasted <iframe src> → page URL, so iframe code maps to this provider too.
fromIframe: (src) => src.host === "player.acme.com" ? `https://video.acme.com/v${src.pathname}` : null,
toEmbed: (url, [, id]) => ({ src: `https://player.acme.com/${id}`, aspectRatio: "16/9" }),
});
<BlockEditor embedProviders={[acme]} />
// or in a plugin: definePlugin({ name: "acme", embeds: [acme] })Custom providers are tried before the built-ins, and one named like a built-in replaces it. embeds.providers filters custom and built-in providers alike.
HTML#
getHTML() and toHTML write a figure with our own iframe attributes, which parseHTML reads back to the same block:
<figure data-genesis-embed data-provider="youtube" data-url="https://www.youtube.com/watch?v=dQw4w9WgXcQ" data-aspect-ratio="16/9">
<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="YouTube embed" loading="lazy"
referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation"
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write" allowfullscreen
style="width:100%;border:0;aspect-ratio:16/9"></iframe>
<figcaption>Optional caption</figcaption>
</figure>For email and RSS, where iframes don't work, write link cards instead. They read back to the same blocks too:
import { toHTML } from "genesis-editor/parser";
toHTML(blocks, { embeds: "link" });
// <figure data-genesis-embed="link" data-provider="youtube" data-url="…"><a href="https://www.youtube.com/watch?v=…">YouTube: https://…</a></figure>With the embed tool off, embeds in pasted or loaded HTML become link paragraphs (blockquote embed code stays a quote; an exported embed's text becomes paragraphs above its link), and the import report counts them under downgrades. report.embeds counts provider embeds, generic embeds, and iframes the policy turned into links (blocked).