When the Stack Is the Problem: Why I Rebuilt Whimsy Gossip on Next.js
A content site built as an SPA pays costs that don't show up as errors. Here's why Whimsy Gossip moved to Next.js and what it took to get there.

I recently finished migrating Whimsy Gossip, a fantasy podcast and fan film production website, from a React single-page application to Next.js 15. The site looks identical before and after. The architecture is completely different. And the reason it was worth two weeks of work has nothing to do with me wanting to use a different framework.
It has to do with what the original architecture was costing, quietly, over time.
The site and the mismatch
Whimsy Gossip is a content site. It has podcast episodes, fan films, cast and crew profiles, FAQs, an about page, a contact form, and a newsletter. Visitors come to read and watch things. The team manages content through Sanity CMS. Nobody on the team is writing code to publish an episode.
The original stack (React 18, Vite, React Router, with Sanity as the CMS backend) is a perfectly reasonable way to build a web application. It's flexible, fast to develop in, and deploys to Vercel without configuration. The problem is that "web application" describes software where users do things: they log in, create records, interact with state. Whimsy Gossip is not that. It's a content site. About 95% of its pages are "fetch content from Sanity and display it." That's the opposite shape from what a single-page application is optimized for.
When there's a mismatch between the architecture and what the site actually does, you pay for it. The payments are usually quiet and accumulate slowly.
What the mismatch was actually costing
Search visibility. A single-page application works by sending a mostly empty HTML document to the browser, then using JavaScript to fetch data and render the page. That's fine for a browser. Search engine crawlers, though, see the empty HTML first. If they don't wait for JavaScript to run (and they frequently don't, or they deprioritize it), they index blank pages. Episode URLs, fan film pages, cast profiles: all potentially invisible or poorly indexed, not because the content wasn't there but because it loaded after the part that crawlers care most about.
The site also had 276 lines of structured data markup: the Schema.org JSON-LD that tells search engines exactly what kind of content a page contains. That markup was injected via JavaScript after the page loaded. For crawlers reading the initial response, it wasn't there.
Every page load started from scratch. There was no server-side rendering, which meant every page visit started with an empty shell, waited for JavaScript, then fetched the relevant content from Sanity. To manage this, the codebase had grown 11 separate data-fetching context providers (one per content type), totaling about 3,500 lines. There was also a separate caching layer to avoid re-fetching the same content too often. Every new page or feature meant another context, another loading state to wire up.
This is one of the underappreciated costs of Single Page Applications (SPA) for content sites: the complexity doesn't disappear, it just moves. Instead of the framework handling data fetching and caching, you build it yourself. And each piece you build yourself is a piece that can drift, go stale, or get out of sync with the others.
A security issue that was silently not working. The site's contact and newsletter forms were protected by CSRF tokens to prevent cross-site request forgery attacks. In the code, the protection looked correct. In production, it wasn't running. The implementation stored a token hash in a Vercel environment variable that's read-only at runtime, so the verification step was checking against nothing, passing every request through regardless. This wasn't a catastrophic exposure, but it was real. The form endpoints were accepting requests without verifying they came from the actual site.
Importantly, this wasn't a bug that was easy to catch. It worked in local development, where environment variables behave differently. It looked right in code review. The production behavior was invisible without specifically testing against a live deployment.
The case for migrating
The argument for Next.js here wasn't "it's a better framework in general." It was that Next.js is specifically built for what this site actually does: render content from a CMS and serve it to visitors.
The features that make it a good fit for Whimsy Gossip are boring ones. Server-side rendering means the initial HTML response contains the actual page content, so crawlers see it immediately. Incremental Static Regeneration means pages are pre-built and served instantly, refreshing automatically when the CMS content changes. generateMetadata() per route means every page's title, description, and Open Graph tags are in the initial response. next/image handles automatic image optimization (WebP and AVIF conversion, lazy loading, correct sizing) without a custom component.
These are things the original stack was either handling manually with significantly more code, doing imperfectly, or skipping altogether.
The CSRF issue was also an argument for migration. The correct fix was a signed-cookie HMAC pattern, a well-established approach that works correctly under Vercel's Edge runtime constraints. Retrofitting that into the existing architecture was possible but awkward; building it correctly into the new API routes took the same amount of effort with a cleaner result.
How the migration went
I planned it in phases, with each phase ending in a state that could be deployed. This matters for a live site: a migration with a hard cutover date is a single point of failure. Phases 0 through 5 ran alongside the existing SPA, porting routes incrementally while keeping the original live. Phase 6 was the final cutover: a build target swap in Vercel, the original SPA rewrite rule removed, and the Playwright test suite run one more time to confirm everything held.
Before touching any page, I wrote a suite of automated tests against the existing site. This is something I'd do again on any migration of this kind. The tests covered the critical paths: homepage renders with content, episodes list loads, the global audio player activates when you press play, fan film detail pages render, forms are interactive, the sitemap returns URLs. One test was deliberately written to fail against the old site and pass against the new one: the 404 path. SPAs typically return a 200 status on unknown routes via a catch-all rewrite. When the Next.js build went live, proper 404 responses started returning. That test flipping from fail to pass was the clearest possible signal that the migration was done.
The one design constraint worth noting was the audio player. Whimsy Gossip has a global audio player that persists while you navigate between pages. You can start an episode and browse to cast profiles or fan films without the playback stopping. In a SPA, this is essentially free: React's component tree survives route changes, so the player stays mounted. In Next.js, where navigations can unmount and remount server-rendered pages, keeping the player alive requires it to live at the root of the layout, above the page-level rendering. This constraint shaped how the entire application shell was structured. It's not a difficult problem, but it's the kind of thing you need to think through before starting rather than discover mid-migration.
What changed
The end state, after six phases over roughly two weeks:
Every page on the site now renders its content in the initial HTML response. Episode and fan film pages have their titles, descriptions, and structured data present on the first request, without JavaScript. The sitemap generates automatically on build and refreshes with ISR, replacing a 408-line custom script that had to be run manually. Images are served in modern formats with automatic sizing. The contact and newsletter forms now go through properly secured API endpoints.
About 5,000 lines of code were deleted with no loss of features. The 11 Sanity data contexts are gone. The loading contexts, the cache layer, the custom image loader, the custom sitemap generator. All replaced by framework equivalents. What remains is less code doing more.
The broader point
The business case for a migration like this is almost never "the current thing is broken." Whimsy Gossip worked. Visitors could browse episodes and watch fan films. The newsletter form submitted. From the outside, there was no visible problem.
The case is about what the architecture is costing over time in ways that are easy to miss: search visibility you're not getting, complexity you're maintaining instead of the framework, security assumptions that need manual verification rather than structural guarantees. None of those costs show up as an error on screen.
The question I'd ask of any content-driven site built as an SPA is the same one I asked here: is the complexity you're carrying earning anything? If the site doesn't need client-side routing, why manage it yourself? If the CMS handles content, why fetch it on the client?
Sometimes the answer is that the dynamic behavior is necessary and the complexity is justified. For Whimsy Gossip, it wasn't. The migration got the site to a shape that matches what it actually does.
Tags