sdxc

Type to search, or start from one of these:

[ Content & feeds ]

Embed YouTube videos in markdown

Turn a YouTube URL pasted on its own line into a lazy-loaded, privacy-enhanced player, keeping its start time, with a plain link left for feeds.

Last updated 2026-10-13

An author sharing a video pastes its URL, the way they would in a chat. This guide turns that URL into a player when the post renders: the markdown stays a plain link that reads fine on GitHub and in an editor, and the page shows the video. It uses the embeds plugin from @sdxc/markdown to find the URL and YouTubeEmbed from @sdxc/ui to draw the player.

npm add @sdxc/markdown @sdxc/ui @sdxc/result remix

The player loads from www.youtube-nocookie.com, so a reader receives no YouTube cookies until they press play, and it loads lazily, so a video further down the post costs nothing until the reader scrolls to it.

Paste the URL

Put the URL on a line of its own:

Some songs never get old.

https://www.youtube.com/watch?v=dQw4w9WgXcQ

Some links never stop working either.

A paragraph becomes a player when it holds a single link that shows its own URL: written bare, in angle brackets, or as a www. autolink. A link with a label of its own, like [the song](https://youtu.be/dQw4w9WgXcQ), or a URL in the middle of a sentence stays a link, so an author decides which videos play in place.

Every URL form YouTube hands out works: a watch?v= page, a youtu.be share link, and the shorts, live and embed paths. A t or start parameter carries over, so a link shared from the chorus starts the player there:

{% title="Rick Astley — Never Gonna Give You Up" %}
https://youtu.be/dQw4w9WgXcQ?t=43

The annotation on the line above is optional. Its title names the player for screen readers, which announce it before a reader enters the frame.

Turn the URL into a tag

embeds() is a visitor for Markdown.walk. Given the youtube() provider, it replaces each pasted YouTube URL with a youtube tag and leaves every other paragraph as it was:

app/services/content/prepare.ts
import { Markdown } from "@sdxc/markdown";
import { embeds, youtube } from "@sdxc/markdown/plugin/embeds";
import { unwrap } from "@sdxc/result";

export function preparePost(source: string) {
	let { document } = unwrap(Markdown.parse(source));
	return unwrap(Markdown.walk(document, embeds([youtube()])));
}

The share link with the annotation above becomes this node, shown without its position:

{
	"type": "tag",
	"name": "youtube",
	"attributes": {
		"title": "Rick Astley — Never Gonna Give You Up",
		"id": "dQw4w9WgXcQ",
		"start": 43,
		"url": "https://youtu.be/dQw4w9WgXcQ?t=43"
	},
	"children": []
}

id is the video, start is in seconds whichever form the link wrote it in (43, 43s, 1m30s), and url is the link as the author wrote it. Matching reads the URL alone, so the walk makes no request and works the same in a build script and inside a request.

When a post already runs other visitors, Markdown.compose runs them in one walk: Markdown.compose(embeds([youtube()]), highlight).

Draw the player

YouTubeEmbed takes videoId, an optional start, and a required title. A small component maps the tag's attributes onto it, and supplies a title when the author wrote none:

resources/components/youtube-tag.tsx
import type { Handle, RemixNode } from "remix/component";

import { YouTubeEmbed } from "@sdxc/ui";

export function YouTubeTag(
	handle: Handle<{
		id: string;
		start?: number;
		title?: string;
		url: string;
		children: RemixNode;
	}>,
) {
	return () => (
		<YouTubeEmbed
			videoId={handle.props.id}
			start={handle.props.start}
			title={handle.props.title ?? "YouTube video player"}
		/>
	);
}

Register it under the tag's name when you render the document:

app/services/content/render-post.tsx
import type { Markdown } from "@sdxc/markdown";

import { toRemix } from "@sdxc/markdown/remix";

import { YouTubeTag } from "~/resources/components/youtube-tag";

export function renderPost(document: Markdown.Document) {
	return toRemix(document, { components: { youtube: YouTubeTag } });
}

The pasted chorus link renders as this frame, plus the classes its styles generate:

<iframe
	data-slot="youtube-embed"
	src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=43"
	title="Rick Astley — Never Gonna Give You Up"
	loading="lazy"
	referrerpolicy="strict-origin-when-cross-origin"
	allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
	allowfullscreen
></iframe>

The frame fills the width of its container at 16:9, so it follows the column it sits in. YouTube plays only when it receives the embedding page's origin as referrer, which is why the frame sets its own referrerpolicy whatever the page's policy is. YouTubeEmbed works outside markdown too: <YouTubeEmbed videoId="dQw4w9WgXcQ" title="Never Gonna Give You Up" /> in any view draws the same player. A video at the top of a page can pass loading="eager" to start loading with it.

A youtube tag with no component registered renders nothing, so register YouTubeTag wherever the walked document renders, or skip the walk there and the URL stays a link.

Allow the player in your Content Security Policy

A page with a frame-src directive, or a default-src without one, blocks frames from any origin it does not list. Add the player's host:

config/security-policy.ts
import type { SecurityHeaders } from "@sdxc/security-headers";

export const SECURITY_POLICY: SecurityHeaders.Policy = {
	contentSecurityPolicy: {
		defaultSrc: ["self"],
		frameSrc: ["https://www.youtube-nocookie.com"],
	},
};

Security headers and CSP covers the rest of the policy and how to roll it out in report-only mode first.

Feed readers and mail clients strip <iframe>, so the HTML you render for them should keep the video as a link. toHTML renders a tag through the function registered under its name, and a tag without one renders its children, which an embed has none of. Register a renderer that writes the link back:

app/services/content/render-feed.ts
import type { Markdown } from "@sdxc/markdown";
import type { HTMLTagRenderer } from "@sdxc/markdown/html";

import { toHTML } from "@sdxc/markdown/html";

const youtubeLink: HTMLTagRenderer = ({ attributes }) => {
	let url = new URL("https://www.youtube.com/watch");
	url.searchParams.set("v", String(attributes.id));
	if (typeof attributes.start === "number") {
		url.searchParams.set("t", `${attributes.start}s`);
	}
	let href = url.href.replaceAll("&", "&amp;");
	return `<p><a href="${href}">${href}</a></p>`;
};

export function renderFeedItem(document: Markdown.Document): string {
	return toHTML(document, { tags: { youtube: youtubeLink } });
}

The link is rebuilt from id and start, which the provider has already checked, so it never carries anything else the author pasted. The &amp; keeps the markup valid in an Atom feed, which an XML parser reads.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.