Whimsy Gossip
A fantasy podcast and fan film site I built twice: first in React 18 / Vite as an SPA, then migrated to Next.js 15 App Router when the content-driven architecture started creating real friction for SEO and maintenance.

Case Study
I built Whimsy Gossip twice. The first time in React 18 / Vite, replacing a template-based landing page that the podcast had outgrown; the second in Next.js 15, when the SPA architecture started creating real friction for a site that had become almost entirely content-driven. Both builds are worth discussing, because the first made reasonable choices that were right for where the project was, and the second was necessary because the site had matured past them.
The initial build
Whimsy Gossip started as a passion project for a friend. A fantasy book podcast that had grown a real audience and needed a proper web presence. The scope was clear: episodes, fan films, cast and crew profiles, a newsletter, and a contact form, all driven by Sanity CMS so the team could manage content without a developer in the loop.
I reached for React 18 / Vite because it was the right fit for how I wanted to work. Fast local dev, no configuration overhead, familiar patterns, straightforward Vercel deployment. Sanity was the obvious CMS choice regardless of framework. React Router handled the 14+ routes. I built a custom global audio player with context so it could persist across navigations. A first-class feature, not an afterthought. The data layer used context providers per content type, each with a useEffect → fetch → setState pattern and a shared CacheContext to avoid redundant Sanity queries.
The design came together well. Dark fantasy aesthetic with a gold gradient color system, parallax hero sections, Tailwind for layout, Font Awesome Pro for icons. The cookie consent system was custom-built with category-level preferences (essential / analytics / marketing) and GDPR-compliant storage. I decided to own it rather than delegate to a third-party widget, partly for flexibility and partly because I wanted to understand the implementation.
What started creating friction
Over time, the shape of the site and the shape of the architecture drifted apart. Whimsy Gossip is a content site. About 95% of its pages do one thing: fetch content from Sanity and display it. That's not what a SPA is optimized for.
The data layer grew to 11 separate context providers, roughly 3,500 lines of provider/consumer plumbing. A CacheContext with a 10-minute in-memory TTL was essentially reinventing Next.js's fetch cache. A custom useLazyData hook and a custom <LazyImage> component were doing work the framework could handle. Every new page meant another context, another loading skeleton, another useEffect. The maintenance surface kept expanding.
The SEO situation was the clearest problem. react-helmet-async sets <title>, OG tags, and JSON-LD after JavaScript hydrates, which means crawlers reading the initial HTML see empty <head> elements. The site had 276 lines of StructuredData.tsx schema.org markup that was largely invisible on first crawl. For a content site trying to grow through organic discovery, that's a real cost.
The migration
I planned the migration in seven phases, each ending in a deployable state. Before touching any file, I wrote a Playwright smoke suite against the existing site: home renders with content, audio player activates on play, fan film detail pages load, forms are interactive, sitemap returns URLs. One test was deliberately test.fail(): the 404 path, which the SPA returned a 200 on via Vercel's catch-all rewrite. That test was designed to flip to passing at Phase 6 cutover. It did, and that's when I called the migration complete.
Static routes went first. Each became an async server component: call Sanity directly, get generateMetadata(), set revalidate = 600, lose the skeleton. The 408-line custom sitemap generator became app/sitemap.ts in about 50 lines. Dynamic routes (/fan-films/[slug], /page/[slug], /forms/[slug]) followed with generateStaticParams and ISR.
The audio player was the constraint that shaped the rest of the layout. In the SPA, React's component tree persisted across route changes, so the player just stayed mounted. In the App Router, navigations can unmount page-level components, so the player had to live in app/layout.tsx as a root-level client component. Everything else server-renders around it. AudioPlayerContext is one of only two contexts that survived the migration intact; the other is CookieAnalyticsContext, which needed dynamic(..., { ssr: false }) to avoid a hydration mismatch from its null-during-init behavior.
AnnouncementBannerContext was reading sessionStorage at module initialization with no typeof window guard: a guaranteed SSR crash. Trivial fix, but a good example of why I'd now grep for window, localStorage, sessionStorage, and document at the start of any SPA-to-SSR migration rather than finding each one during porting.
The CSRF situation came up during the pre-migration review. The original implementation stored a token hash in process.env.TOKEN_HASH from inside an API handler, which is read-only at Vercel Edge runtime. The protection wasn't running in production. I replaced it with a signed-cookie HMAC pattern: GET /api/csrf issues a <random>.<hmac> token and sets it as a non-httpOnly cookie; mutating routes verify the X-CSRF-Token header against the cookie and validate the HMAC signature using a server-only CSRF_SECRET. This is now my default pattern for any Vercel-hosted project with form submissions.
The final delta was about 5,000 lines deleted with no feature loss. LoadingContext, CacheContext, useLazyData, react-helmet-async, react-router-dom, vite, the custom sitemap script, the custom <LazyImage>, the root Express dev server and its handler stubs. All gone. What replaced them is less code doing more.
What I'd do differently
The original React build was the right call for where the project was. I'd make the same framework choice again at that stage. What I'd add: a note in the project docs that if the site ever needed to care about SEO, the data architecture would need to change. Framing it as a known future cost rather than discovering it organically would have made the migration decision cleaner.
The hydration trap audit (the grep for browser globals) is now something I'd run at the start of any migration, not partway through. Three of the four SSR issues I hit were predictable from the codebase's client-centric patterns.