Bitdoze logo

How to Add YouTube Videos to Your Astro Blog (SSG + SSR)

Learn how to add YouTube videos to your Astro blog with Content Layer loaders (SSG) and server:defer islands (SSR), plus caching, thumbnails, troubleshooting.

Dragos

Updated Published 41 min read

How to Add YouTube Videos to Your Astro Blog (SSG + SSR)

Want to add YouTube videos to your Astro blog without updating anything by hand? This guide shows how to automatically pull a channel’s latest videos into your Astro 7 site at build time with Content Layer loaders (SSG) or on demand with server:defer server islands (SSR).

Both approaches use YouTube’s free RSS feed (no API key needed), and I’ll cover the real-world gotchas: why the RSS endpoint sometimes 404s, why maxresdefault.jpg can break your build, and how to upgrade to the YouTube Data API when you need more resilience.

Whether you’re using the free Astro blog theme or your own setup, this guide has copy-pasteable code for both methods.

🎯 What You’ll Learn

  • SSG with Content Layer custom loader: the recommended build-time approach with type safety
  • SSR with server:defer server islands: on-demand rendering for real-time updates
  • YouTube RSS feed: how it works, why it can 404, and how to build resilience
  • Thumbnail optimization: hqdefault.jpg fallback to avoid broken images
  • YouTube Data API upgrade path: still free at blog scale, more reliable than RSS
  • Troubleshooting: RSS outages, missing thumbnails, island failures

🛠️ Prerequisites

  • Node.js 22.12.0+. Astro 6+ dropped Node 18 and 20. Pin it in .nvmrc:
    text
    22.12.0
  • Astro 7.x. This guide targets the current stable (7.2.10 as of Aug 2026). If you’re starting fresh, check the free Astro blog guide.
  • Basic JavaScript/TypeScript knowledge
  • A YouTube channel with the channel ID (see below)
  • An adapter (Node or Cloudflare). Only required for the SSR/server islands method.

Astro 7 brings stricter HTML

Astro 7 uses a Rust-based compiler and Vite 8. Build times drop 15-61%, but unclosed HTML tags now error the build instead of silently correcting. If you’re copy-pasting code blocks, double-check your markup. See what changed in Astro 7 for the full breakdown.

Find your YouTube channel ID from an @handle

Channel URLs now show @handles, not IDs

YouTube channel URLs look like youtube.com/@yourchannel. That’s a handle, not the UC... ID the RSS feed needs. Here’s how to find the real ID:

  1. Go to your channel page in a browser
  2. Right-click → View Page Source
  3. Search (Ctrl+F) for "channelId":"UC or "externalId":"UC
  4. Copy the UC... string

Alternatively, open any video from the channel and search the page source the same way. The RSS feed only returns the ~15 most recent videos per channel.

Companion video

📡 How the YouTube RSS Feed Works (and Why It Can 404)

Both methods in this guide use the same zero-cost endpoint:

text
https://www.youtube.com/feeds/videos.xml?channel_id=YOUR_CHANNEL_ID

No API key, no authentication, no quota. It returns an Atom XML feed with the ~15 most recent videos from the channel: video ID, title, published date, thumbnail URL, and more.

RSS feed reliability

YouTube’s RSS endpoint has documented intermittent 404 and 500 errors. These outages tend to cluster around 09:00-12:00 UTC, affect different channels randomly, and can last hours. A browser User-Agent header does not reliably fix it.

This means a naive build-time fetch can fail on a normal deploy. The mitigation: use AbortSignal.timeout() for bounded fetch time, add a retry with backoff, and for SSG keep a last-good JSON snapshot committed in the repo so a YouTube hiccup never bricks your build.

Why RSS over API?

RSS is free and keyless, but it’s not SLA’d and limited to ~15 videos. The YouTube Data API is the resilient, pro-grade path, and it stays free at blog scale (1-3 API units per refresh out of 10,000/day). I cover the upgrade path in the Data API section below.

The modern SSG approach uses Astro’s Content Layer API with a custom loader. This replaces the old pattern of fetching RSS in a component’s frontmatter with regex parsing. Benefits: type safety via Zod, data-store caching, astro sync types, and the component stays presentation-only.

Step 1: Create the YouTube content loader

Install the XML parser:

bash
npm install fast-xml-parser

Create the loader at src/loaders/youtube.ts:

ts
// src/loaders/youtube.ts
import type { Loader } from 'astro/loaders';
import { z } from 'astro/zod';
import { XMLParser } from 'fast-xml-parser';

export function youtubeVideosLoader(options: {
  channelId: string;
  maxVideos?: number;
}): Loader {
  return {
    name: 'youtube-videos-loader',
    load: async ({ store, parseData, logger }) => {
      const url = `https://www.youtube.com/feeds/videos.xml?channel_id=${options.channelId}`;
      const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });

      if (!res.ok) {
        logger.error(`YouTube feed returned ${res.status}`);
        return; // keeps prior data in store, build won't fail
      }

      const xml = new XMLParser({ ignoreAttributes: false }).parse(
        await res.text()
      );
      const entries = (xml.feed?.entry ?? []).slice(0, options.maxVideos ?? 6);

      store.clear();
      for (const entry of entries) {
        const id = entry['yt:videoId'];
        const data = await parseData({
          id,
          data: {
            id,
            title: entry.title,
            published: new Date(entry.published),
            thumbnail: `https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
            url: `https://www.youtube.com/watch?v=${id}`,
          },
        });
        store.set({ id, data, digest: JSON.stringify(data) });
      }
    },
    schema: z.object({
      id: z.string(),
      title: z.string(),
      published: z.date(),
      thumbnail: z.string(),
      url: z.url(),
    }),
  } satisfies Loader;
}

Why a Content Layer loader?

A Content Layer loader gives you type safety (Zod schema), data-store caching (prior data survives a failed fetch), and astro sync generates collection types. The component that renders the videos stays dumb, it just calls getCollection(). This is the pattern Astro recommends for external data at build time.

Step 2: Register the loader in content.config.ts

ts
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { youtubeVideosLoader } from './loaders/youtube';

export const collections = {
  youtube: defineCollection({
    loader: youtubeVideosLoader({
      channelId: import.meta.env.YOUTUBE_CHANNEL_ID,
      maxVideos: 6,
    }),
  }),
};

Add your channel ID to .env:

bash
YOUTUBE_CHANNEL_ID=UCGsUtKhXsRrMvYAWm8q0bCg

Then generate the collection types:

bash
npx astro sync

Run astro sync after wiring the loader

npx astro sync generates TypeScript types for your collections. Run it after any change to content.config.ts. The types give you autocomplete and catch errors at build time.

Step 3: Render the videos with <Image>

The component now just calls getCollection('youtubeVideos') and renders. No fetching, no parsing, that’s the loader’s job.

astro
---
// src/components/YouTubeVideosSSG.astro
import { Icon } from "astro-icon/components";
import { Image } from "astro:assets";
import { getCollection } from "astro:content";

const videos = await getCollection("youtubeVideos");
const channelId = import.meta.env.YOUTUBE_CHANNEL_ID;
---

<section class="py-8 bg-white dark:bg-gray-900 rounded-lg">
  <div class="max-w-5xl mx-auto px-4 sm:px-6">
    <div class="text-center mb-8">
      <h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-4">
        Latest YouTube Videos
      </h2>
      <p class="text-lg text-gray-600 dark:text-gray-300">
        Check out our latest content on YouTube
      </p>
    </div>

    {videos.length === 0 ? (
      <div class="text-center py-8">
        <p class="text-gray-600 dark:text-gray-400">
          No videos found.
        </p>
      </div>
    ) : (
      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {videos.map((video) => (
          <div class="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 border border-gray-200 dark:border-gray-700">
            <div class="relative">
              <a href={video.data.url} target="_blank" rel="noopener noreferrer">
                <Image
                  src={video.data.thumbnail}
                  alt={video.data.title}
                  width={320}
                  height={180}
                  class="w-full h-48 object-cover rounded-t-lg"
                  loading="lazy"
                  format="webp"
                />
              </a>
              <a href={video.data.url} target="_blank" rel="noopener noreferrer">
                <Icon
                  name="mdi:play-circle"
                  class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white w-16 h-16 drop-shadow-lg opacity-80 hover:opacity-100 transition-opacity"
                />
              </a>
            </div>
            <div class="p-4">
              <a href={video.data.url} target="_blank" rel="noopener noreferrer">
                <h3 class="text-lg font-semibold text-gray-900 dark:text-white line-clamp-2 mb-2 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
                  {video.data.title}
                </h3>
              </a>
              <p class="text-sm text-gray-500 dark:text-gray-400">
                {video.data.published.toLocaleDateString("en-US", {
                  year: "numeric",
                  month: "short",
                  day: "numeric",
                })}
              </p>
            </div>
          </div>
        ))}
      </div>
    )}

    <div class="text-center mt-8">
      <a
        href={`https://www.youtube.com/channel/${channelId}`}
        target="_blank"
        rel="noopener noreferrer"
        class="inline-flex items-center px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors duration-300"
      >
        <Icon name="mdi:youtube" class="w-5 h-5 mr-2" />
        View All Videos
      </a>
    </div>
  </div>
</section>

<style>
  .line-clamp-2 {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
</style>

Add the component to any page:

astro
---
// src/pages/index.astro
import YouTubeVideosSSG from '../components/YouTubeVideosSSG.astro';
---

<html>
  <body>
    <YouTubeVideosSSG />
  </body>
</html>

This works with the free Astro blog theme, drop the component into your layout or index page.

🚀 Method 2: SSR, server:defer Server Islands

The SSR approach uses server:defer to create a server island. The page loads instantly with static HTML, and the YouTube component renders on demand on the server. No client-side JavaScript needed.

Step 1: Install an adapter

server:defer requires a server adapter. You do not need output: 'server', keep the default 'static' output and only the island renders on demand. This is lighter than server-rendering every page.

Node Adapter

bash
npx astro add node

This configures astro.config.mjs:

js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({
    mode: 'standalone',
  }),
});

Build and run:

bash
astro build && node dist/server/entry.mjs

For production, deploy the SSR build to a VPS, an affordable VPS like Hetzner or Hostinger works well for this.

VPS prices jumped across the board in 2026 — if you’re rethinking a rented box, see what changed and when a mini PC wins.

Cloudflare Adapter

bash
npx astro add cloudflare

Cloudflare adapter changes in Astro 6+

The Cloudflare adapter has breaking changes since Astro 6: the dev server now runs on workerd for parity, and you need Wrangler ^4.125.0. Check the generated compatibility_date in your wrangler.toml. See deploying your Astro blog to Cloudflare Pages for the full setup.

Step 2: Create the SSR component with server:defer

astro
---
// src/components/YouTubeVideosSSR.astro
import { Icon } from "astro-icon/components";

export interface Props {
  channelId: string;
  maxVideos?: number;
}

const { channelId, maxVideos = 6 } = Astro.props;

let videos: Array<{
  id: string;
  title: string;
  published: Date;
  thumbnail: string;
  url: string;
}> = [];
let error: string | null = null;

try {
  const response = await fetch(
    `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`,
    { signal: AbortSignal.timeout(10_000) }
  );

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const xmlText = await response.text();

  // Parse XML to extract video data
  const videoRegex =
    /<entry>.*?<yt:videoId>(.*?)<\/yt:videoId>.*?<title>(.*?)<\/title>.*?<published>(.*?)<\/published>.*?<\/entry>/gs;

  let match;
  while (
    (match = videoRegex.exec(xmlText)) !== null &&
    videos.length < maxVideos
  ) {
    const [, videoId, title, published] = match;
    videos.push({
      id: videoId,
      title: title
        .replace(/&amp;/g, "&")
        .replace(/&lt;/g, "<")
        .replace(/&gt;/g, ">")
        .replace(/&quot;/g, '"')
        .replace(/&#39;/g, "'")
        .replace(/&apos;/g, "'"),
      published: new Date(published),
      thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
      url: `https://www.youtube.com/watch?v=${videoId}`,
    });
  }
} catch (err: any) {
  console.error("Error fetching YouTube videos:", err);
  error = err.message;
}
---

<section class="py-8 bg-white dark:bg-gray-900 rounded-lg">
  <div class="max-w-5xl mx-auto px-4 sm:px-6">
    <div class="text-center mb-8">
      <h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-4">
        Latest YouTube Videos
      </h2>
      <p class="text-lg text-gray-600 dark:text-gray-300">
        Check out our latest content on YouTube
      </p>
    </div>

    {error ? (
      <div class="text-center py-8">
        <p class="text-red-600 dark:text-red-400">
          Unable to load YouTube videos. Please try again later.
        </p>
      </div>
    ) : videos.length === 0 ? (
      <div class="text-center py-8">
        <p class="text-gray-600 dark:text-gray-400">
          No videos found.
        </p>
      </div>
    ) : (
      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {videos.map((video) => (
          <div class="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 border border-gray-200 dark:border-gray-700">
            <div class="relative">
              <a href={video.url} target="_blank" rel="noopener noreferrer">
                <img
                  src={video.thumbnail}
                  alt={video.title}
                  width={320}
                  height={180}
                  class="w-full h-48 object-cover rounded-t-lg"
                  loading="lazy"
                />
              </a>
              <a href={video.url} target="_blank" rel="noopener noreferrer">
                <Icon
                  name="mdi:play-circle"
                  class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white w-16 h-16 drop-shadow-lg opacity-80 hover:opacity-100 transition-opacity"
                />
              </a>
            </div>
            <div class="p-4">
              <a href={video.url} target="_blank" rel="noopener noreferrer">
                <h3 class="text-lg font-semibold text-gray-900 dark:text-white line-clamp-2 mb-2 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
                  {video.title}
                </h3>
              </a>
              <p class="text-sm text-gray-500 dark:text-gray-400">
                {video.published.toLocaleDateString("en-US", {
                  year: "numeric",
                  month: "short",
                  day: "numeric",
                })}
              </p>
            </div>
          </div>
        ))}
      </div>
    )}

    <div class="text-center mt-8">
      <a
        href={`https://www.youtube.com/channel/${channelId}`}
        target="_blank"
        rel="noopener noreferrer"
        class="inline-flex items-center px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors duration-300"
      >
        <Icon name="mdi:youtube" class="w-5 h-5 mr-2" />
        View All Videos
      </a>
    </div>
  </div>
</section>

<style>
  .line-clamp-2 {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
</style>

Use it with server:defer and a fallback slot:

astro
---
// src/pages/index.astro
import YouTubeVideosSSR from '../components/YouTubeVideosSSR.astro';
---

<html>
  <body>
    <!-- The server:defer directive makes this a server island -->
    <YouTubeVideosSSR
      server:defer
      channelId="UCGsUtKhXsRrMvYAWm8q0bCg"
      maxVideos={6}
    >
      <!-- Fallback content shown while the island loads -->
      <div class="text-center py-8">
        <p class="text-gray-500">Loading videos...</p>
      </div>
    </YouTubeVideosSSR>
  </body>
</html>

Serializable props

Props passed to a server:defer island must be serializable. If the serialized props URL exceeds 2048 bytes, the island switches to POST and loses browser caching. For a channelId string and a maxVideos number, you’re well under the limit.

Step 3: Operator notes and caching

A few production concerns for server islands:

Rolling deploys need ASTRO_KEY. If you deploy to multiple regions or do rolling restarts, the encrypted props and slot content (encrypted since Astro 6) need a stable key:

bash
npx astro create-key

Set the output as the ASTRO_KEY environment variable in your CI or hosting platform.

Inside an island, Astro.url points at /_server-islands/..., not the real page URL. If you need the actual page URL, read it from the Referer header.

Astro 7 route caching is now stable. You can cache the island response at the CDN or memory level:

js
// astro.config.mjs
import { defineConfig, memoryCache } from 'astro/config';

export default defineConfig({
  cache: { provider: memoryCache() },
  routeRules: {
    '/': { maxAge: 300, swr: 60 },
  },
});

This is more idiomatic than a hand-rolled Map cache (which silently breaks on serverless/edge because it’s per-process only).

🔑 YouTube Data API Upgrade Path (Resilient and Still Free)

When RSS isn’t enough, you need deeper history, view counts, durations, or just more reliability, the YouTube Data API v3 is the upgrade. At blog scale, it’s effectively free.

The call chain:

  1. channels.list → get the uploads playlist ID (1 unit)
  2. playlistItems.list → get video IDs from the uploads playlist (1 unit)
  3. videos.list → get titles, thumbnails, durations, view counts (1 unit)

Total: 2-3 units per refresh out of a 10,000 unit/day default quota. You’d need to refresh thousands of times a day to hit the limit.

Still free at blog scale

One channel, a couple API calls per build = 1-3 units out of 10,000/day. You’ll never hit the limit. There’s no pay-for-quota option, only a manual audit form if you need more.

Manage the API key with astro:env:

js
// astro.config.mjs
import { defineConfig, envField } from 'astro/config';

export default defineConfig({
  env: {
    schema: {
      YOUTUBE_API_KEY: envField.string({
        context: 'server',
        access: 'secret',
      }),
      YOUTUBE_CHANNEL_ID: envField.string({
        context: 'server',
        access: 'public',
      }),
    },
  },
});

Then in your loader or server code:

ts
import { YOUTUBE_API_KEY, YOUTUBE_CHANNEL_ID } from 'astro:env/server';

const res = await fetch(
  `https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${YOUTUBE_CHANNEL_ID}&key=${YOUTUBE_API_KEY}`
);
const data = await res.json();
const uploadsPlaylistId =
  data.items[0].contentDetails.relatedPlaylists.uploads;
How does YouTube API quota work?

The default quota is 10,000 units per day per Google Cloud project, resetting at midnight Pacific time. Cheap calls: playlistItems.list, videos.list, and channels.list each cost 1 unit. Expensive call: search.list costs 100 units per call (or, per newer docs, gets its own bucket of 100 calls/day at 1 unit each, the quota model has some ambiguity). If you get a 403 with quotaExceeded, you’ve hit the daily limit. For this article’s use case (one channel, 2-3 calls per build), you’ll never come close.

🖼️ Thumbnails and Image Optimization

maxresdefault can break your build

maxresdefault.jpg (1280px) is not available for every video. Older uploads, low-res originals, and some live/restricted content return a 404. In the SSG path with Astro’s <Image> component, a missing remote thumbnail can fail the build because <Image> fetches the remote image at build time.

Use hqdefault.jpg (480px) as your safe default, it’s always present. Also available: mqdefault.jpg (320px) and default.jpg (120px).

Which YouTube thumbnail size should I use?
Size Resolution Always available? Use case
default.jpg 120×90 Yes Tiny previews, not recommended
mqdefault.jpg 320×180 Yes Small cards, mobile
hqdefault.jpg 480×360 Yes Recommended default
sddefault.jpg 640×480 Usually Good balance, rare 404s
maxresdefault.jpg 1280×720 No Only if you verify availability

For the SSG path, use <Image format="webp" loading="lazy"> for automatic optimization at build time. For the SSR island path, a plain <img loading="lazy"> with i.ytimg.com is simpler since remote optimization happens at request time. See speed up your Astro builds for more on build performance.

⚖️ When to Use SSG vs Server Islands

Use SSG when:

  • Performance is critical, static HTML loads instantly
  • You don’t need real-time updates, videos update when you rebuild
  • You want zero server costs, no runtime, just static files
  • SEO matters, static content is easily crawlable
  • You prefer simplicity, no adapter, no server infrastructure

Use server islands when:

  • Real-time updates matter, videos appear right after publishing
  • You already have server infrastructure, Node.js server or Cloudflare Workers
  • You want per-component caching, Cache-Control on the island endpoint
  • You need the latest data without rebuilding, no CI/CD trigger needed

🖼️ Embedding Single Videos in MDX

This guide covers displaying a channel’s video grid. If you need to embed a single YouTube video inside a blog post, use @astro-community/astro-embed-youtube, it wraps lite-youtube-embed for privacy-friendly, performance-optimized embeds (iframe loads only after click, ~229K weekly downloads).

bash
npm install @astro-community/astro-embed-youtube
mdx
import { YouTube } from '@astro-community/astro-embed-youtube';

<YouTube id="x6pjyeQ_6V0" />

For a full walkthrough on responsive single-video embeds in Astro MDX, see embed individual videos in your Astro MDX with astro-embed.

🔍 Troubleshooting Common Issues

RSS feed not loading (404/500)

This is the #1 production issue

YouTube’s RSS endpoint has documented intermittent outages, not a CORS problem. The real risk is a 404 during your build or island render.

Mitigation:

  • Use AbortSignal.timeout(5000) to bound fetch time
  • Add a retry with exponential backoff (1s, 2s, 4s)
  • For SSG: keep a last-good JSON snapshot in the repo; fall back to it when the feed fails
  • Verify the feed is live before deploying: curl "https://www.youtube.com/feeds/videos.xml?channel_id=UC..."

Videos not displaying / broken thumbnails

  1. Check your channel ID, use the page-source method from the prerequisites. A wrong ID returns an empty feed, not an error.
  2. Check thumbnail URLs, grep your built HTML for hqdefault or maxresdefault. If you see maxresdefault 404s, switch to hqdefault in your loader.
  3. Check build logs, the Content Layer loader logs errors via logger.error(). Look for YouTube feed returned 4xx/5xx.
  4. Check the RSS feed directly, open the URL in a browser. If it 404s, wait and retry (outage windows are typically short).

Server island not rendering

  1. Adapter not installed, server:defer requires an adapter. Run npx astro add node or npx astro add cloudflare.
  2. ASTRO_KEY not set, on rolling deploys or multi-region, encrypted props can’t decrypt without a stable key. Run npx astro create-key and set the env var.
  3. Props too large, if serialized props exceed 2048 bytes, the island switches to POST and loses browser caching. Keep props small (channel ID + max count is fine).
  4. Check the endpoint directly, visit /_server-islands/YouTubeVideosSSR (or your component name) to see if the island responds.

YouTube API quotaExceeded

A 403 with quotaExceeded means you’ve hit the daily 10,000-unit limit. At blog scale (1-3 units/build), this shouldn’t happen. If it does, check for accidental search.list calls (100 units each). There’s no pay-for-quota, only a manual audit form for extensions.

✅ Verification Checklist

  1. curl "https://www.youtube.com/feeds/videos.xml?channel_id=UC...", confirm 200 and inspect the XML fields
  2. npx astro sync after wiring the loader, confirm types are generated without errors
  3. astro build, confirm no build errors, check output for thumbnail 404s
  4. Run the standalone server locally, confirm /_server-islands/<Component> returns HTML
  5. Grep built HTML for hqdefault/maxres, catch any broken thumbnail URLs
  6. Open the deployed page in a browser, videos render, thumbnails load, fallback slot swaps out

🎉 Conclusion

The SSG approach with a Content Layer loader is the recommended path for most blogs, it’s fast, type-safe, and the component stays clean. Server islands via server:defer are the right call when you need real-time updates without rebuilding.

Both approaches work with the free Astro blog theme, so you can add YouTube videos to your Astro blog right away.

Pro tip

Combine both approaches: use the Content Layer loader for your main video showcase page and a server:defer island for a “Latest Video” widget in your sidebar that updates in real-time!

Want to take it further? Check out headless CMS options for your Astro blog or host video without YouTube using Bunny Stream. For faster thumbnail delivery at scale, consider a CDN like Bunny.net.


FAQ

Do I need a YouTube API key?

No, the RSS feed (/feeds/videos.xml) works without any authentication. You only need an API key if you upgrade to the YouTube Data API v3 for deeper history, view counts, or better reliability. At blog scale, the Data API is free (1-3 units out of 10,000/day).

How many videos does the RSS feed return?

The RSS feed returns the ~15 most recent videos per channel. If you need more, use the YouTube Data API’s playlistItems.list endpoint, which can paginate through the full uploads playlist.

Can I use this with Cloudflare Pages?

Yes, install the Cloudflare adapter with npx astro add cloudflare. Since Astro 6, the dev server runs on workerd for parity. You need Wrangler ^4.125.0 and should verify the generated compatibility_date. See deploying your Astro blog to Cloudflare Pages for the full guide.

What Astro version do I need?

Content Layer loaders work since Astro 5.0 (Dec 2024). Server islands also work from Astro 5. This guide targets Astro 7 (current stable). From Astro 6 onward, Node 22.12.0+ is required.

Will this work with Astro 5?

Content Layer loaders work since Astro 5.0. Server islands are also supported. However, Astro 6+ requires Node 22.12.0+ and introduced breaking changes to the Cloudflare adapter. If you’re on Astro 5, the code works but you won’t get the build speed improvements from Astro 7’s Rust compiler.