A Podcast Landing Page Is a Registry, a Feed, and One CSP Line
Episodes belong in a typed registry, the feed is the actual product, and the player embed renders blank unless your Content-Security-Policy names its host.
A podcast landing page is three things a marketing page is not: a list that grows forever, a feed other apps subscribe to, and an embedded player you do not host. Build the episode list as a typed registry, treat the feed as the actual product, and add the player's host to your Content-Security-Policy — or the embed renders blank.
The three requirements, and which one bites
Most "how to build a podcast landing page" advice is layout advice: hero, subscribe buttons, episode list, email capture. That part is a landing page like any other. The parts that are genuinely different are these:
| Requirement | Why it's not a normal landing-page problem |
|---|---|
| An episode list that grows weekly | The page's content changes on a schedule the deploy doesn't control |
| A feed at a stable URL | Apple, Spotify and every podcast app read the feed, not your HTML — the feed is the distribution |
| A third-party player embed | It's a cross-origin iframe, which your security headers get a vote on |
The first two are solved problems with a known shape. The third is the one that ships broken, because it fails in a way that looks like a CSS bug.
Episodes are a registry, not a CMS
This storefront's blog has the same shape as an episode list — an ordered set of items with metadata, each with a body, each needing its own page and a listing page. It is built from three files that must agree, and that pattern ports directly to episodes.
The metadata lives in a typed array, separate from the prose:
// src/data/blog.ts — the same shape works for episodes
export interface BlogPostMeta {
slug: string;
title: string;
description: string;
date: string;
readingMinutes: number;
tags: string[];
}
The body lives beside it as MDX, and the two are paired by an explicit loader map:
// src/lib/blog.ts
const postLoaders: Record<string, () => Promise<{ default: ComponentType }>> = {
"nextjs-ci-pipeline": () => import("@/content/blog/nextjs-ci-pipeline.mdx"),
"vercel-vs-aws-amplify": () =>
import("@/content/blog/vercel-vs-aws-amplify.mdx"),
// …one line per item
};
That map is written out by hand on purpose. A dynamic import built from a template literal over the slug works, but it hands the bundler a wildcard: everything in the directory joins the graph, and a typo becomes a runtime 500 instead of a build failure. With explicit entries, a missing file fails next build, and a test asserts the registry, the files and the loader map never drift apart.
For a podcast, the split matters more than it does for a blog. The listing page, the feed, the sitemap and the home-page teaser all need metadata for every episode; only the episode page itself needs the show notes. Keeping them in separate files means listing 200 episodes does not compile 200 bodies.
The embed is a Content-Security-Policy decision
Here is the line that decides whether your player works. This site sends a CSP on every response from next.config.ts:
const contentSecurityPolicy = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://app.lemonsqueezy.com …",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"frame-src 'self' https://*.lemonsqueezy.com https://*.asoctemplates.com https://*.vercel.app",
"frame-ancestors 'none'",
].join("; ");
frame-src is an allowlist of what this site is permitted to put in an iframe. Every host on it is there because a feature needed it: the checkout overlay, and the live template previews the product pages open in a modal. Nothing else can be framed.
Drop a Spotify, Apple or YouTube player onto a page under that policy and the iframe stays empty. There is no layout error, no failed request in the Network tab that looks like yours, and no React error — the browser refuses the frame and logs a CSP violation in the console, which is the one place people don't look when a box renders blank. The fix is one entry (https://open.spotify.com, or whichever host your player is served from), and the discipline is that it is one entry, not a wildcard: frame-src * gives every script on the page the ability to frame anything.
If you serve audio files yourself instead of embedding a hosted player, the directive you need is media-src, not frame-src — and note that default-src 'self' already covers same-origin audio, which is why a self-hosted <audio> element needs no CSP change at all.
The iframe itself is worth treating as a component rather than a tag. This codebase's preview modal mounts its iframe only while open, tracks which URL finished loading rather than a bare boolean (so switching episodes resets the loading state with no extra effect), and traps focus while it's open. An audio embed wants the same three properties.
The feed is the product
A podcast landing page that renders beautifully and has no feed is a poster. The feed is what listeners actually subscribe to, and it is a route, not a page.
This site publishes RSS 2.0 at /blog/feed.xml as a Route Handler that opts into static generation:
// src/app/blog/feed.xml/route.ts
export const dynamic = "force-static";
Without that line the feed is a dynamic route: recomputed per request, cached by nothing, and a small permanent cost on a file whose content only changes when you deploy. With it, the feed is built once and served as a static asset.
Three details carry over to a podcast feed, and one does not:
- Dates are RFC-822, not ISO-8601. RSS 2.0's
pubDatewantsTue, 01 Sep 2026 00:00:00 GMT. This is the single most common reason a validator rejects an otherwise-fine feed. - Escape five characters, every time —
&,<,>,",'— in every title and description you interpolate. - URLs must be absolute. Feed readers have no base URL to resolve against. This repo centralises that in one
siteUrl()helper after a doubled-slash bug reached production JSON-LD. - What's different: a podcast feed needs an
<enclosure>per item (the audio file's URL, byte length and MIME type), a stable<guid>, and theitunes:namespace elements the directories require. A blog feed needs none of those. Build the enclosure'slengthfrom the real file size — directories do check it.
Prerender the episode pages, and check that you did
Every episode page is known at build time, which means every one of them should be a static file. This site's most recent build prerendered 414 pages across 27 static routes, 3 dynamic-segment routes and 8 genuinely dynamic ones. The blog's episode-page equivalent adds one line:
// src/app/blog/[slug]/page.tsx
export const dynamicParams = false;
With generateStaticParams returning every slug and dynamicParams = false, an unknown slug 404s instead of being rendered on demand — which is what you want for a fixed set of episodes, and what keeps the route table honest. Read the route table in your own build output: a ƒ next to your episode route means you are paying for a server render of content that changed the last time you deployed.
Podcast landing page examples: what to actually copy
An honest note on examples, since that's the query underneath this one. This catalog has 111 products, 66 of them landing pages, and not one of them is a podcast template — the only occurrence of the word "podcast" anywhere in src/data/catalog.ts is a single use-case bullet ("Podcast & video scripts") on an AI-workspace SaaS product. So the recommendation here is not "buy ours."
What is worth copying comes from the publishing templates, because a show page is a publication with an audio body — the same two UI problems, solved:
- A feed of episodes with format markers. ASoc Press ships gallery, video and audio article layouts alongside an editor's-pick carousel — the "this item is audio" affordance an episode grid needs, already built.
- A featured item above a running feed. ASoc Quill leads with a featured-post hero over a latest-stories feed with format markers, which is the exact shape of "latest episode, then the archive".
The structural lesson from looking at real ones: the subscribe row belongs above the episode list, not below it. Somebody who already knows the show is looking for their app's button, and somebody who doesn't is going to sample an episode — both of those are above the fold, and neither is your email form.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| Player iframe renders as a blank box, no visible error | frame-src in your CSP doesn't include the player's host | Add the exact host; check the browser console for the CSP violation report |
| Feed validates locally, rejected by a directory | pubDate emitted as ISO-8601 | Format RFC-822, and test with a date whose day-of-week you can verify |
| Episode links work in the app, break in a podcast client | Relative URLs in the feed | Build absolute URLs from one helper; never string-concatenate a base in more than one file |
| New episode published, listing page still shows the old list | The listing route is cached and nothing revalidated it | For a build-time registry, redeploy is the publish step — that's the tradeoff of not having a CMS |
Episode page renders per request (ƒ in the route table) | generateStaticParams missing, or a dynamic API used in the page | Return every slug and set dynamicParams = false |
| Feed shows every episode's full show notes and weighs a megabyte | Summaries and full content aren't separated in the registry | Keep the description in metadata, the body in the content file |
Frequently asked questions
Do I need a CMS for a podcast landing page? Not until someone who cannot open a pull request needs to publish. A typed registry plus a content file per episode gives you type-checked metadata, build-time failures for broken links, and no runtime database — and the publish step is a deploy. The moment a producer needs to publish without you, that tradeoff flips.
Should the audio be self-hosted or embedded from my podcast host?
Embed the host's player if the host is already serving your audio to the directories, because bandwidth for audio is the one cost that scales with success. Self-host only the sample clip in the hero, if you want one that starts instantly. Note that the two choices need different CSP directives: frame-src for the embed, nothing extra for a same-origin <audio>.
Does the landing page need its own RSS feed if my host already publishes one? No — one feed, one canonical URL. Point your subscribe buttons at the host's feed rather than publishing a second one; two feeds for one show is how listeners end up subscribed to a copy that stops updating.
How many episodes before the listing page needs pagination? Later than you think. This site's own blog renders well over a hundred items on one page as a static file, and the practical limit is the HTML size, not the count. Paginate when the page's transferred bytes start showing up in your metrics — not on a round number.
Templates in this post
ASoc Press is the closest structural match in the catalog — a news and magazine template with dedicated audio article layouts, category directories and a paginated archive, which is an episode registry with the UI already written. ASoc Quill is the lighter version of the same idea: a featured-post hero over a latest-stories feed with format markers, plus the newsletter subscribe block a show page needs for listeners who won't use an app. ASoc Synth is the one to look at if the show is the marketing arm of a product — an AI-workspace SaaS site whose own use-case list names podcast and video scripts, and the case where the page has to sell the product and the show at once.
Browse the full sets: Next.js landing page templates and Tailwind landing page templates.
