A self-hosted YouTube video feed, built to work inside an AEM instance that only allows iframe embeds.
This project is a small static site (hosted on Cloudflare Pages) that renders a YouTube video feed and is designed to be dropped into that one iframe, configured entirely through the iframe's own attributes. SFU's AEM External Video component is lacking. So, I decided to create this solution. The AEM setup only needs embedding an <iframe> and hosting one JS file.
One feed layout, with potential to create more in the future:
latest-videos-styled.html — SFU-branded thumbnail cards; clicking a card swaps its thumbnail for a live embed in place.If no playlist or videos are configured, the iframe renders completely blank — no placeholder text.
The page reads its content from URL query parameters:
| Param | What it does |
|---|---|
playlist | A YouTube playlist ID or playlist link (e.g. the channel's uploads playlist). Shows the latest videos from it. A channel ID (UC…) or youtube.com/channel/… link also works and shows the channel's uploads. Channel handle links (@name) don't. |
max | How many videos to show from the playlist, from 1 to 10. Defaults to 2. Private and deleted videos are skipped automatically. |
videos | A list of specific YouTube links (or bare video IDs) to show instead of a playlist, separated by commas, spaces or line breaks. Up to 50. Always overrides playlist/max when present. |
titleStyle | Leave empty for titles as a bolded paragraph matching the body font (the default). Set to heading for an h3 heading instead. |
latest-videos-styled.html?playlist=UUxxxxxxxxxxxxxxxxxxxxxx&max=6
latest-videos-styled.html?videos=https://youtu.be/dQw4w9WgXcQ,https://youtu.be/xvFZjo5PgG0
latest-videos-styled.html?playlist=UUxxxx&titleStyle=heading
A YouTube channel's "uploads" playlist ID is its channel ID with the UC prefix swapped for UU. Pasting the channel ID itself does this swap automatically.
Rather than hand-building that query string inside AEM, embeds use plain data-* attributes — a shared loader script reads them and builds the URL (with correct escaping) automatically:
<script src="https://www.sfu.ca/content/dam/sfu/politics/CERG/code/js/iframe.js"></script>
<iframe class="sfu-video-embed"
src="https://cerg-youtube-feed.pages.dev/latest-videos-styled"
data-videos=""
data-playlist=""
data-max-videos=""
data-title-style=""
title="CERG Youtube Feed" width="100%" height="800px" frameborder="no"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
allowfullscreen
style="border-style: none; overflow: hidden; transition: height 0.2s ease;">
</iframe>
Fill in whichever data-* attribute matches what you want to show and leave the rest empty — leaving all of them empty shows nothing. The iframe also auto-resizes to fit its content height via postMessage, handled by that same script.
Keep fullscreen in allow and the allowfullscreen attribute — without them, YouTube's fullscreen button can't work inside the feed. The feed only displays when embedded on www.sfu.ca pages; other sites see a blank frame (set by frame-ancestors in _headers).
_worker.js) proxies requests to the YouTube Data API v3, so the API key never reaches the browser. Two routes: /api/videos (playlist-based) and /api/videos-by-id (specific videos, used by ?videos=)._routes.json sends only /api/* to the Worker; every other file is served directly. _headers adds the feed page's security headers.YOUTUBE_API_KEY secret, set in Cloudflare Pages → Settings → Variables and Secrets.iframe.js — the AEM-side loader that builds each embed's URL from its data-* attributes and handles the resize listener — is hosted separately on SFU's own CDN, not in this repo.
YOUTUBE_API_KEY with that key.iframe.js somewhere AEM can load it from, and embed the iframe snippet above wherever the feed is needed.