I built the technical stack for ⌘+P : The App Podcast the way an app developer would build an app — not the way a podcaster duct-tapes Spotify for Podcasters. Here's what happens when you treat RSS as a product.
The first episodes of ⌘+P were recorded at Podspace in Paris — a real booth, real microphones, a real sound engineer who doesn't pretend to understand when you start talking about AES bus versus monitoring output. Editing happens on my Mac in Final Cut Pro, because let's be honest: an Apple developer cutting his podcast in Premiere would be like an Italian chef putting cream in carbonara. People notice, and they judge. Rightly.
What I underestimated is that recording and editing are roughly 20% of the work. The rest is distribution. And distributing a podcast in 2026 is far more booby-trapped than it looks.
This article is the technical post-mortem of the stack I built for ⌘+P: three different RSS feeds, a headless CMS, a custom CDN, an adaptive video encoder, and an unapologetic bias toward Apple Podcasts. If you're considering launching a serious podcast without going through Acast or Spotify for Podcasters, this is probably the densest piece you'll read on the subject this year.
The bet: treat RSS as a product
The question that saved me six months of wandering: do I want my podcast to live on someone else's platform, or do I want it to live with me?
Spotify for Podcasters (formerly Anchor) offers an apparently unbeatable deal: upload your .wav, they generate the RSS feed, host the audio, and push it to Apple Podcasts. Free. The catch, obviously, is that your RSS feed belongs to them. The day you want to switch hosts, you take your back catalog with you but you lose your statistics, your canonical links, and sometimes your standing with the directories that indexed you from the original Anchor feed. Acast is the same logic with a more polished UX and a monthly bill.
I picked the opposite. The RSS feed is an endpoint I control, on my domain, generated by my code, from my source of truth. Apple, Spotify, Pocket Casts, Castro, Overcast — they all subscribe to me. If Spotify turns hostile tomorrow, I change two lines of XML, not my entire host.
That decision cascades. If I own the RSS, I also have to own everything that goes with it: the CMS, audio and video file storage, encoding, CDN, chapters, Podcasting 2.0 metadata, per-platform variants. It's more work. It's also infinitely more educational, and the resulting stack is surprisingly lean.
The source of truth: Hygraph
Content lives in Hygraph, a headless GraphQL CMS. I have a PodcastEpisode model that carries everything you'd expect — season, episode number, guests, chapters, audio and video URLs, rich-text show notes, dates, isPrivate and showOnHomepage flags. Guests are a separate PodcastGuest entity with their own rich-text bio and linkedinUrl, because the same guest can come back, and duplicating a bio across two entries is the kind of sin you regret six months later. Video lives on a reusable Video entity that PodcastEpisode references — I'll come back to that, it became central.
The site consuming all this is Next.js 16 App Router with Turbopack and Tailwind v4, deployed on Clever Cloud with git-push-to-redeploy. The GraphQL layer uses Apollo Client. i18n runs through next-intl with seven locales (en, fr, de, it, nl, ar-AE, ar-SA) and localePrefix: 'as-needed'. Detail that matters: the show is francophone, so even when a visitor lands on /de/podcast, the RSS forces locales: ['fr', 'en'] server-side against Hygraph. The RSS isn't multilingual — it's bilingual by nature, and Apple Podcasts expects that, regardless of where you click.
Storage: Clever Cloud Cellar behind Cloudflare
Media files (audio, video, covers) live on Clever Cloud Cellar, their S3-compatible object storage, behind a Cloudflare CDN at cdn2.clementsauvage.me. The choice isn't obvious, so let me justify it.
Mux is tempting for everything — encoding, streaming, distribution. Except at the scale of a video podcast that can weigh 250 MB per episode, the Mux bill becomes unreasonable fast once you multiply by RSS download volume. Cellar is predictable per-gigabyte pricing, native byte-range support (verified — Range: bytes=0-1023 returns a clean HTTP 206), and the app is already on Clever Cloud so inter-service latency is negligible. Byte-range is non-negotiable, because podcast clients never download a file in one shot: they stream progressively, and they expect to resume.
Mux is still in the mix, but only for web streaming: adaptive HLS via playbackId, played by mux-player-react on the episode page. Embedded video on the site benefits from Mux's pro encoding ladder, and the master MP4 H.264 on Cellar exclusively serves RSS distribution. Two use cases, two files, two strategies. More storage to pay for, but each file does its job without compromise.
The import admin: where context comes first
I have a private endpoint, a tool that takes a raw .mp3 from Final Cut, a JPEG cover, and produces a draft episode in Hygraph ready for review.
The UX lesson that cost me a redesign: context must come before content. First version had files at the top and Season / Episode / Type / Language fields at the bottom. Logical from a form-design standpoint. Catastrophic from a pipeline standpoint, because those four fields determine the upload path on Cellar (cmd-p-podcast/s{n}/episode-{m}/audio.wav). If you drop your files before picking the season, either the server refuses, or it guesses and you end up with episode 12 stored in the episode 11 folder. I flipped it: 2×2 grid of context first, drop zone after. The form won't accept uploads until those four fields are filled. Bug never happened again.
The upload itself is presigned multipart S3 from the browser: the server generates presigned URLs per 64 MB chunk, the browser PUTs directly to Cellar, the server collects ETags to assemble the multipart on completion. A dedicated endpoint configures the bucket's CORS with ExposeHeaders: ETag because without it, the browser can't return ETags and complete fails silently. A detail you won't find in any mainstream S3 doc.
For teasers, I use a specific naming convention: a 7-character alphanumeric suffix from a 55-character alphabet (no 0, 1, I, O, i, l, o to avoid visual confusion). The key is regenerated each time TEASER mode is selected and shared across the three files of a teaser (audio/video/cover). It lets multiple teasers coexist for the same season/episode without overwrite, and the URL stays pronounceable over the phone. Three lines of code, and it saves you from telling your guest you accidentally overwrote their teaser with someone else's.
The full pipeline post-upload: Mux ingests the video and produces an asset + playback ID + thumbnail at a chosen timestamp. AssemblyAI transcribes the audio and generates auto-chapters. An edit-friendly preview shows up in the admin: short description, show notes, chapters (title / start / end). Everything is editable before the Hygraph write. On confirm, we create a draft PodcastEpisode with a stable episodeGuid UUID, the Mux asset referenced, the AssemblyAI transcript, and chapters stored separately from the show-notes blob — the site UI and the <podcast:chapters> JSON surface them independently, so merging would be an architectural mistake you'd pay for on every update.
The RSS: three feeds, three strategies
This is where it gets interesting. Apple Podcasts is treated as the priority. It gets the main feed, the only one published in real time, the only one that receives every Podcasting 2.0 feature. Spotify and the rest come second.
Concretely, three endpoints:
/podcast/rss— main feed, episodes published immediately, full Podcasting 2.0/podcast/rss/late— same content delayed by 3 days/podcast/rss/spotify— audio-only feed, 3-day delay, strict Spotify compliance
The 3-day delay is a deliberate editorial choice. Apple Podcasts gets the episode Tuesday morning. Spotify and secondary directories get it Friday. During those 72 hours, the audience coming from Apple Podcasts has an exclusive head start. It's my way of rewarding the platform that carried podcasting for twenty years, and incidentally pushing my listeners toward the client that best respects what a podcast actually is — an RSS file, not an algorithmic playlist.
In code, the filter is !episode.isPrivate && isVisibleInDelayedFeed(...). Semantics of isPrivate that cost me a solid half-hour of confusion: a private episode is hidden from RSS but visible on the site. That's the exact opposite of what my brain reads at first glance, so I left a comment in the code and noted it here so I never make the mistake again. It lets me push a teaser or a patrons-only episode to the site without polluting Apple or Spotify.
What goes into the Apple feed
The main feed embraces Podcasting 2.0 without reservation. The xmlns:podcast namespace is declared on <channel>, and we emit:
<podcast:guid>— a stable UUID for the show, decoupled from the feed URL<podcast:updateFrequency rrule="FREQ=WEEKLY;INTERVAL=2;BYDAY=TU" dtstart="2026-05-05T05:00:00Z">Every other Tuesday</podcast:updateFrequency>— the official cadence, machine-parsable<podcast:person>for the host (Clément Sauvage, role="host", href, Cellar avatar) and one per dynamic guest (role="guest", Hygraph avatar, name + company in text content)<podcast:chapters>referencing an external JSON inapplication/json+chapters<podcast:alternateEnclosure>to offer audio + video in the same<item>
On the iTunes namespace side, we emit the full set: itunes:author, itunes:type set to episodic, itunes:image (the mandatory 3000×3000 cover — more on that), itunes:category set to Technology, itunes:explicit, itunes:owner with itunes:name and crucially itunes:email. On items: itunes:episodeType (full or trailer), itunes:title, itunes:episode, itunes:season, itunes:duration in seconds, and the episode image with channel fallback.
The main <enclosure> points to the H.264 MP4 video when the episode has one, and the audio moves to podcast:alternateEnclosure. Without video, audio takes the main <enclosure> slot. MIME type is derived from the URL via a mimeTypeFromUrl function that sniffs the path extension while ignoring query strings — Apple requires a valid extension, and your Cellar URLs may have signature query params that would fail validation without that precaution.
The cover trap: 3000×3000 or nothing
The cover, I designed in Figma. It's become my default tool for pretty much any static visual asset — clean vector, trivial multi-resolution exports, and variants (show cover, episode covers, chapter thumbnails) stay in sync via components. Which brings me to the trap.
Apple Podcasts demands a square cover, 3000×3000 minimum, JPEG. First Figma export, first upload, Apple rejects: 3000×3004. Four pixels too tall, because a Figma frame with internal padding that's not perfectly even doesn't show up to the eye but is enough to crash the validator. Feed refused. I re-framed to exactly 3000×3000 in Figma, re-exported, re-uploaded to cdn2.clementsauvage.me/cmd-p-podcast/cmdp-cover.jpg, verified with curl that accept-ranges: bytes came back, and resubmitted. Approved.
If you only learn one thing from this article: check your dimensions to the pixel before submitting. Apple doesn't give you a clear error message, just a rejection, and you're left guessing. And set your Figma frame to 3000×3000 explicitly, not "roughly square."
Chapters in external JSON
Rather than inlining chapters in the XML, the Podcasting 2.0 standard recommends an external JSON file. I have a /podcast/{slug}/chapters.json endpoint serving the PodcastIndex 1.2.0 spec:
{
"version": "1.2.0",
"title": "...",
"podcastName": "⌘+P: The App Podcast",
"author": "Clément Sauvage",
"chapters": [
{ "startTime": 0, "endTime": 90, "title": "Intro", "img": "...", "url": "..." }
]
}
Image fallback cascade: chapter.imageUrl from Hygraph, otherwise Mux thumbnail at t=startTime, otherwise episode cover. Content-Type explicitly set to application/json+chapters; charset=utf-8 because the Next default of application/json isn't recognized by all clients. Cache s-maxage=900, stale-while-revalidate=3600 so I'm not hammering Hygraph on every tap in Castro.
The double <description> and <content:encoded>
Where most hosts settle for a minimalist <description>, I emit both: a plain-text <description> for universal compatibility (including older clients that don't parse HTML), and a <content:encoded> carrying a clean HTML version of the same content. Both contain show notes, a Timeline section with timestamps formatted MM:SS or H:MM:SS past one hour, a condensed host bio, and per-guest bios with the header With {name} — {role} at {company}.
Time-waster I want you to dodge: Hygraph rich-text extracted via .text came out with literal \n (the backslash-n string, not a real newline) because they were stored JSON-encoded. An unescapeNewlines function converts those before render. Without it, my feed displayed \n as visible text in every podcast client. Hello credibility.
The Spotify feed: everything Apple loves, removed
Spotify is demanding and idiosyncratic. Their official spec forbids or ignores a chunk of what Apple loves. My /podcast/rss/spotify feed is therefore a strict variant:
- No
podcast:*namespace — Spotify doesn't know what to do with it and prefers it not exist - No
podcast:alternateEnclosure— same logic - No
atom:link— Spotify prefers without itunes:ownerwithitunes:email— required for feed verification by their support team- No redundant
itunes:titleon items - Audio only — Spotify doesn't support video over RSS, so
<enclosure>is always MP3 or WAV - Chapters in Podlove Simple Chapters format (not Podcasting 2.0):
<psc:chapters version="1.1">
<psc:chapter start="0:00" title="..." href="..." image="..." />
</psc:chapters>
With the namespace xmlns:psc="http://podlove.org/simple-chapters" added only if the episode has chapters, because declaring an unused namespace makes some validators grumble.
All this is handled by a mediaMode: 'default' | 'audio' parameter passed to getPodcastRss(...). The builder is single, the feeds are different projections. No code duplication, no drift between versions.
RSS video encoding: H.264 and nothing else
I tried to push HEVC because it's more efficient, because my Mac Studio encodes it in hardware, because my files are 40% smaller. Mistake. HEVC doesn't fly on the RSS path. Pocket Casts, Overcast, Castro don't decode HEVC in a podcast context. Apple Podcasts accepts it via direct upload to Podcasts Connect but not via RSS. The result: a file half your listeners can't play, with no clear error.
The rule for RSS: MP4 H.264, AAC stereo, period. Here's the ffmpeg recipe I use:
ffmpeg -i source.mov \
-c:v libx264 -profile:v high -preset slow -crf 23 \
-vf "scale=1280:720" -pix_fmt yuv420p -movflags +faststart \
-c:a aac -b:a 256k -ar 48000 \
out.mp4
A few choices that aren't obvious:
- 720p, not 1080p or 4K. For talking heads in low-motion conversation, 1280×720 is the sweet spot. 4K is wasted, 1080p adds weight without perceptible benefit.
- CRF 23 with preset slow rather than fixed bitrate. Slower to encode, but the rate control is better and static scenes save bytes automatically.
- AAC 256 kbps stereo — Apple's recommendation, and 128 kbps audibly degrades speech.
-movflags +faststartmoves the moov atom to the file's beginning. Without it, the podcast client has to download the whole file before playback can start. With it, progressive streaming works from the first kilobytes.-pix_fmt yuv420p— without this flag, ffmpeg can output yuv444p that some players refuse. Six characters, hours of debugging avoided.
Concrete result: a teaser went from 120 MB (FCP H.264 export at 9.8 Mbps) to 26.4 MB, no visible difference. A 22-minute episode lands around 250 MB, which is reasonable for an RSS download over mobile.
And if you use Permute to batch your encodes: always uncheck "Allow copying of HEVC tracks". Otherwise the tool stream-copies and you end up with an MP4 containing HEVC, which is exactly what you wanted to avoid.
File size in RSS, and why Video.sourceSizeBytes
Small detail that changes everything for podcast clients: the length attribute on <enclosure> must be the exact byte size of the file. Apple Podcasts uses it to compute the download progress bar. If the value is wrong or missing, some clients show broken progress, and others outright refuse to download.
I already had audioSizeBytes on PodcastEpisode, computed at upload. When I added video to the main <enclosure>, I needed the equivalent on the Video entity: a new Video.sourceSizeBytes field, optional. Since PodcastEpisode already has a relation video toward Video, I added Video.sourceUrl on that entity too rather than duplicating the URL on PodcastEpisode. The RSS code reads video.sourceUrl and video.sourceSizeBytes when the episode has a video, otherwise falls back to audioSizeBytes and the audio URL.
GraphQL types regenerated after each Hygraph migration (src/gql/graphql.ts and src/gql/gql.ts), and the RSS code handles both cases cleanly. Five extra lines in the builder, but the kind of five lines that take you from "works on my machine" to "works in Castro on 4G."
AI-assisted production: intro video, trailer, RSS
Three places where I let generative models do the work, and I'd argue this is also what makes the stack viable for a solo operator.
The show's intro video — the pre-roll that opens every episode — started from a simple iPhone photo, run through the Happy Horse 1.0 video model to animate it. The result lasts a few seconds but gives the show a clean visual signature, whereas I'd never have shipped decent motion design starting from After Effects on a weekend. Marginal cost is trivial, render is consistent with the identity, and nobody asks "who made your intro" — which is the goal.
The audio trailer that runs on social and that will ship via <itunes:episodeType>trailer</itunes:episodeType> was produced with Suno. Not for the usual background music (which I'd still rather compose or license cleanly), but to generate a short musical teaser calibrated for the podcast trailer format — punchy, identifiable, rights clear. The show's main theme follows a different, more artisanal logic, but for a trailer whose useful lifespan is a few weeks, Suno is exactly the right tool.
And then, especially, Codex. The RSS portion of this stack — particularly the cohabitation of the three feeds, the mediaMode switching, the chapter variants between Podcasting 2.0 and Podlove Simple Chapters, the dynamic namespace per episode, the newline unescaping — was co-written with Codex in pair-programming mode. Not generated and pasted, co-written: me setting the contract (what each feed must produce, what Podbase validates, what Spotify rejects), Codex proposing the XML mechanics, me iterating on the edge cases that only a human who's had his feed rejected actually knows. The result is code I read in full and put my name on, but I wouldn't have written it as fast alone. Claiming otherwise would be dishonest, and I've lost a bit of patience with developers who still pretend in 2026 that this slows them down.
That's also why this stack fits in three weekends and €12 a month. It's not about being smarter than before, it's about having tools that make solo craftsmanship viable where it would have required a team five years ago.
Validation and debugging: what saved me
Three tools I recommend without hesitation:
Podbase validates your feed against Apple, Spotify, and Podcasting 2.0 specs. It flagged my missing podcast: namespace early on (which I'd forgotten to declare), a false positive on byte-range "not supported" (the validator was testing against an empty feed because all my episodes were isPrivate), and another false positive on "0% of enclosures with valid extension" for the same reason. Conclusion: validate with at least one public episode in the feed, otherwise you'll chase bugs that don't exist.
curl + ffprobe + MediaInfo for verifying enclosures: HTTP 200, content-type, accept-ranges, video codecs (H.264 vs HEVC), exact dimensions (1280×720 vs whatever). A curl -I on your enclosure URL tells you in three seconds whether Cellar is responding right. A ffprobe -v error -show_entries stream=codec_name,width,height tells you in two seconds whether your file is compliant.
Clever Cloud logs + local bun build to reproduce runtime crashes. Two examples that each cost me an evening:
- A
DYNAMIC_SERVER_USAGEerror from Apollo doing an uncached fetch during the render of a SSG route. Fix:Promise.allSettledaround the Hygraph query in the OG image route, so a 5xx Hygraph doesn't take down the card generation. - A Satori crash
Cannot read properties of undefined (reading '256')while rendering the dynamic OG image with the variable font Rethink Sans. Satori doesn't support variable fonts. Lesson committed to long-term memory: no variable fonts innext/og. I replaced the ⌘ rendered as text with an inline SVG and the crash disappeared.
Structural decisions, in retrospect
If I had to summarize the philosophy in five points:
SSG everywhere except the player. The /podcast page and each /podcast/[slug] are statically generated via generateStaticParams. The Mux video player and the <audio> tag are the only client components that stream on demand. SEO maxed out, pages cacheable at the CDN, time-to-first-byte under 100ms.
Hygraph as the single source of truth. No separate database, no schema migrations to manage, no custom admin panel to maintain. The import admin writes drafts, I manually validate each episode, Hygraph publishes. One canonical state.
Cellar as primary storage, Mux as optional luxury. The H.264 MP4 master lives on Cellar and serves RSS. Mux has its own encode of the same video as adaptive HLS for the web. If Mux disappeared tomorrow, the site loses adaptive streaming but the podcast keeps shipping without interruption.
Podcasting 2.0 as first-class, not an afterthought. podcast: namespace, person tags, chapters JSON, updateFrequency, alternateEnclosure. The investment is low since I emit the XML myself anyway. The payoff in Castro, Pocket Casts, Fountain is massive.
Apple Podcasts first, made concrete. 3-day lead time over Spotify. All Podcasting 2.0 tags. Pixel-perfect 3000×3000 cover. Loyalty to the platform that invented the format deserves real technical effort, not just a kind tweet (Cupertino, it'll cost you a Dubdub golden ticket, xxx)
Where this leads
⌘+P isn't a marketing exercise to sell consulting hours, even though I'm a consultant. It's a craftsman's project, asking whether one developer can build a modern, clean podcast pipeline without depending on anyone, using 2026 tools rather than 2018 tools. The answer, after two months of tunneling, is yes — but only if you accept two things. That RSS is a product in itself, not a file you generate at the end. And that AI-assisted production isn't a shameful shortcut but an integral part of what makes the solo operation viable: Figma for visuals, Happy Horse for the intro video from an iPhone photo, Suno for the trailer, Codex for half the RSS code. Without those tools, I wouldn't do this podcast. With them, I do it well.
Next iteration probably includes transcripts served via <podcast:transcript> (I already have SRT from AssemblyAI, it's three lines of XML), an English localization of the feed for the anglophone audience, and an analytics dashboard parsing Range requests on Cellar to reconstruct per-episode listening curves. No Spotify Wrapped, no Chartable, just my own logs.
If you're building your podcast right now and hesitating between Acast and DIY: the stack I just described took roughly three weekends of code and runs at about €12/month in infrastructure (Clever Cloud + Cellar + Hygraph free tier + Mux free tier up to a certain volume). It's more than zero. It's also the difference between renting your feed and owning it.
The first episode of ⌘+P drops Tuesday, May 5, 2026. If tech, product, software craftsmanship, or simply people who build things well speak to you, subscribe on Apple Podcasts first — not because it's better, but because it lands three days earlier.
And if you've got an exceptional project, reach out. Innovating is what makes me tick.
