Skip to main content
ASoc
Tutorial

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.

The ASoc Team10 min read

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:

RequirementWhy it's not a normal landing-page problem
An episode list that grows weeklyThe page's content changes on a schedule the deploy doesn't control
A feed at a stable URLApple, Spotify and every podcast app read the feed, not your HTML — the feed is the distribution
A third-party player embedIt'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 pubDate wants Tue, 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 the itunes: namespace elements the directories require. A blog feed needs none of those. Build the enclosure's length from 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

SymptomCauseFix
Player iframe renders as a blank box, no visible errorframe-src in your CSP doesn't include the player's hostAdd the exact host; check the browser console for the CSP violation report
Feed validates locally, rejected by a directorypubDate emitted as ISO-8601Format RFC-822, and test with a date whose day-of-week you can verify
Episode links work in the app, break in a podcast clientRelative URLs in the feedBuild absolute URLs from one helper; never string-concatenate a base in more than one file
New episode published, listing page still shows the old listThe listing route is cached and nothing revalidated itFor 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 pageReturn every slug and set dynamicParams = false
Feed shows every episode's full show notes and weighs a megabyteSummaries and full content aren't separated in the registryKeep 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.

Keep reading

Tutorial13 min read

Programmatic SEO in Next.js: Derive the Pages, Don't List Them

A hand-maintained slug list left 24 of our products on no category page at all, and nothing failed. Here is the derived hub-and-spoke build that fixed it.

Read more