Embed

Turns a URL into what it points to, decided from the URL alone

import { Embed } from "@/components/ui/embed";
export default function EmbedDemo() {
return (
<div className="w-full max-w-[560px]">
<Embed url="https://www.youtube.com/watch?v=eVjP9Vnh2xE" />
</div>
);
}

Installation

npx shadcn@latest add https://www.deltacomponents.dev/r/embed.json

Usage

1import { Embed } from "@/components/ui/embed";
2
3<Embed url="https://www.youtube.com/watch?v=eVjP9Vnh2xE" />

YouTube

A watch, share or shorts link becomes a thumbnail and a button. The player itself is close to a megabyte of third-party script, so on Android and desktop it only loads once someone presses play, from youtube-nocookie.com; on iOS the player is mounted eagerly once the block nears the viewport, since a tap on a cross-origin iframe there won't otherwise land inside it and start the video with sound.

Once the video is playing, a tap on a touch screen is ours, not YouTube's: it toggles play and pause over the player's message API. Left to the player, a tap brings up its chrome, and the title bar across the top of a phone-height embed is a link into the YouTube app, so trying to pause opened the app instead. The strip along the bottom stays YouTube's, for scrubbing, volume and fullscreen. A mouse can aim, so on desktop the player keeps every click.

The corners are square by default; pass className="rounded-lg" for a radius.

import { YouTubeEmbed } from "@/components/ui/embed";
export default function EmbedYouTube() {
return (
<div className="w-full max-w-[560px]">
<YouTubeEmbed videoId="eVjP9Vnh2xE" title="But what is a neural network?" />
</div>
);
}

Spotify

A track or episode link gets the compact player; an album, playlist or show gets the tall one. Either way the iframe's src is only set once the player is about to scroll into view, so a page of link drops doesn't front-load every player on it at once.

import { SpotifyEmbed } from "@/components/ui/embed";
export default function EmbedSpotify() {
return (
<div className="w-full max-w-[560px]">
<SpotifyEmbed type="track" id="6K4t31amVTZDgR3sKmwUJJ" />
</div>
);
}

Anything else valid becomes a card. The URL alone is enough: with the preview route installed, LinkPreview fetches the page's own title, description and image and fills the card in. Pass title, description or image yourself to skip the fetch, and endpoint={null} to turn it off outright. Without the route — or before the fetch resolves — the card stays the hostname with the address beneath it, which is what a pasted link with nothing else settles for.

import { LinkPreview } from "@/components/ui/embed";
export default function EmbedLink() {
return (
<div className="w-full max-w-[560px]">
<LinkPreview url="https://patrickprunty.com" />
</div>
);
}

Image

Any link whose path ends in an image extension. It is drawn with the registry's Image, so it enlarges on a click, and ImageEmbed takes everything that component does: a caption, width and height, bleed, zoomable={false} for a plain picture. Installing Embed brings Image with it.

import { ImageEmbed } from "@/components/ui/embed";
export default function EmbedImage() {
return (
<div className="w-full max-w-[560px]">
<ImageEmbed
src="/images/editor-embed-sample.webp"
alt="A painted battle scene of knights on horseback in red, green and yellow"
/>
</div>
);
}

Video

An .mp4, .webm or .mov plays where it sits, with the browser's own controls, held back the same way the Spotify player is.

Pass a poster when you have one: it shows at once and costs nothing until someone presses play. Without it the opening frame stands in, which Safari only paints when asked for a moment just past zero, so the component asks.

The corners are square by default here too; pass className="rounded-lg" for a radius.

import { VideoEmbed } from "@/components/ui/embed";
export default function EmbedVideo() {
return (
<div className="w-full max-w-[560px]">
<VideoEmbed src="/videos/swainsons-hawk.mp4" poster="/videos/swainsons-hawk-poster.webp" />
</div>
);
}

From untrusted text

Embed is the one part here that doesn't trust its input. The others (YouTubeEmbed, ImageEmbed and the rest) take whatever a developer hands them; Embed takes a raw string, such as a pasted line or a comment body, and decides what it is with classifyUrl.

Two rules do the work. Only an absolute http(s) URL counts: a root- or protocol-relative path would load from the reader's own origin, with their cookies. And image or video is decided from the URL's path alone, so a query string can't pass an endpoint off as one.

This is the same classifier the Editor's Extensions example pairs with a small CodeMirror widget, so a link alone on a line in the editor becomes exactly what it becomes here.

The preview route

A browser can't read another site's HTML — cross-origin fetch from the page itself is blocked, so filling in a link's title, description and image needs a server that can. Embed and LinkPreview call /api/link-preview for that; installing the component installs the route alongside it, at app/api/link-preview/route.ts.

It refuses:

  • Anything but http/https, a non-default port, or a URL carrying a username or password.
  • Any address the DNS lookup resolves to that isn't on the public internet — loopback, the private ranges, link-local (where cloud metadata endpoints live), and their IPv6 equivalents — checked at connection time, so a hostname can't pass the check and then resolve somewhere else.
  • More than 3 redirects, each re-checked the same way as the original URL.
  • A response that isn't text/html or application/xhtml+xml — the fields come back empty rather than erroring.

And it limits itself to a 5-second timeout and 512 KiB of the response body, since a preview's tags are in the head.

Two caveats. It needs the Node runtime, not the edge — dns.lookup's custom resolver depends on it. And rate limiting is deployment-specific — a CDN, middleware, an API gateway — so it's left to you.

API Reference

Embed

YouTubeEmbed

SpotifyEmbed

VideoEmbed

ImageEmbed

LinkPreview