Tech 4 min read

Lightweight YouTube Embeds in Astro: Lazy Loading with a Rehype Plugin

IkesanContents

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

  1. Build time: the rehype plugin detects YouTube iframes
  2. Transform: replace them with thumbnail + play-button HTML
  3. Render time: only the lightweight thumbnail is shown, with no YouTube JS loaded
  4. 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 AST
  • rehype-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.