Lightweight YouTube Embeds in Astro: Lazy Loading with a Rehype Plugin
Contents
Problem: YouTube Embeds Are Heavy
If you use the official YouTube embed code as-is, the page fetches a lot of resources on load. Even if the viewer never watches the video, JavaScript and thumbnail images are preloaded.
It also uses a fixed size (width="560" height="315"), so it overflows on mobile.
<iframe width="560" height="315"
src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
Looking at Existing Libraries
There is a YouTube embed library for Astro called astro-embed, and it supports lazy loading.
But the usage is like this:
---
import { YouTube } from 'astro-embed';
---
<YouTube id="VIDEO_ID" />
That only works in MDX. Writing <YouTube id="..." /> in a .md file will not work.
Rewriting all existing articles to MDX is not realistic. I wanted to keep Markdown as-is and automatically make embeds lazy-load.
Solution: Automatically Transform with a Rehype Plugin
Astro’s Markdown pipeline supports rehype, the plugin system for HTML. If you build a rehype plugin, you can detect iframes at build time and transform them into a lazy-loading structure.
Flow
- Build time: the rehype plugin detects YouTube iframes
- Transform: replace them with thumbnail + play-button HTML
- Render time: only the lightweight thumbnail is shown, with no YouTube JS loaded
- On click: JavaScript creates the iframe dynamically and starts autoplay
Implementation
1. Install Dependencies
pnpm add unist-util-visit rehype-raw
pnpm add -D @types/hast
unist-util-visit: utility for walking the ASTrehype-raw: needed to parse raw HTML in Markdown as AST nodes
2. Create the Rehype Plugin
import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
export function rehypeYouTubeEmbed() {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
if (node.tagName !== 'iframe') return;
const src = node.properties?.src as string | undefined;
if (!src) return;
// Extract the video ID from the YouTube URL
const match = src.match(
/(?:youtube\.com|youtube-nocookie\.com)\/embed\/([a-zA-Z0-9_-]+)/
);
if (!match) return;
const videoId = match[1];
// Convert to a lazy-loading structure
node.tagName = 'div';
node.properties = {
className: ['youtube-embed'],
'data-video-id': videoId,
};
node.children = [
{
type: 'element',
tagName: 'img',
properties: {
src: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
alt: 'YouTube video thumbnail',
loading: 'lazy',
},
children: [],
},
{
type: 'element',
tagName: 'button',
properties: {
className: ['youtube-play-button'],
'aria-label': 'Play video',
},
children: [],
},
];
});
};
}
You can fetch YouTube thumbnails from https://i.ytimg.com/vi/{VIDEO_ID}/maxresdefault.jpg.
3. Add the Plugin to Astro Config
import { rehypeYouTubeEmbed } from './src/lib/rehype-youtube-embed';
import rehypeRaw from 'rehype-raw';
export default defineConfig({
markdown: {
rehypePlugins: [rehypeRaw, rehypeYouTubeEmbed],
// ... other settings
},
});
Running rehype-raw first converts the raw HTML in Markdown into AST nodes, which rehypeYouTubeEmbed can then process.
4. Style It with CSS
.youtube-embed {
position: relative;
width: 100%;
max-width: 560px;
aspect-ratio: 16 / 9;
margin: 1.5rem 0;
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
background: var(--secondary);
}
.youtube-embed img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.2s;
}
.youtube-embed:hover img {
opacity: 0.8;
}
.youtube-play-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 68px;
height: 48px;
background: rgba(255, 0, 0, 0.9);
border: none;
border-radius: 12px;
cursor: pointer;
}
.youtube-play-button::before {
content: '';
border-style: solid;
border-width: 10px 0 10px 18px;
border-color: transparent transparent transparent white;
margin-left: 4px;
}
.youtube-embed iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
aspect-ratio: 16 / 9 keeps it responsive. It does not overflow on mobile.
5. JavaScript for Click-to-Load
function initYouTubeEmbeds() {
const embeds = document.querySelectorAll('.youtube-embed');
embeds.forEach((embed) => {
if (embed.querySelector('iframe')) return;
if (embed.dataset.initialized) return;
embed.dataset.initialized = 'true';
embed.addEventListener('click', () => {
const videoId = embed.getAttribute('data-video-id');
if (!videoId) return;
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1`;
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
iframe.allowFullscreen = true;
iframe.title = 'YouTube video player';
embed.innerHTML = '';
embed.appendChild(iframe);
});
});
}
Result
This makes YouTube embeds lightweight:
- The page shows only the thumbnail until clicked
- The iframe is loaded only when needed
- It works inside regular Markdown
- No need to convert everything to MDX
This is much closer to the workflow I wanted.