A person standing beside a massive, ancient redwood tree in a California old-growth forest, illustrating scale and endurance

Why I Migrated From Next.js to Express in 2026

In April 2026, I moved the global milk map, serving 100,000+ monthly visitors across 60+ countries, off Next.js and onto a plain Express and EJS stack. This is the full story: the specific bugs that made the case, the general research that held up under scrutiny once I went looking for it, and the parts of the decision that were closer to philosophy than evidence.

I’ve written seven pieces over the past few weeks working through different parts of this, each researched and fact-checked on its own terms rather than leaning on a single before-and-after benchmark. This piece ties them together with the connective narrative.

PieceFinding
Social embeds break on Next.js route changesDocumented, unresolved since 2019; tied to how the browser handles scripts inserted via dangerouslySetInnerHTML
Memory leak on self-hosted search routesCaching an unbounded route space with a fixed TTL bounds entry lifespan, not entry count
Alpine.js replaced ReactRoughly a tenth of the bundle size for a site that never needed a component-tree rendering model
React SSR blocks Node’s event loopDocumented directly by Expedia’s engineering team and a live, unresolved React GitHub issue
Next.js hides cache observability that Redis gives nativelyNo built-in way to know whether a response was cached, for self-hosted deployments, confirmed in Next.js’s own GitHub discussions
Server-rendered templating needs no build stepEJS compiles once at first render; deploying means restarting a process, not shipping a pipeline’s output
Next.js has 11x more known CVEs than ExpressSame source, same date; two of the critical ones are as severe as web application security gets

TL;DR

  • Two specific, documented Next.js bugs directly motivated parts of this migration: social embeds silently breaking on client-side navigation, and a memory leak from caching an unbounded, high-cardinality route space.
  • Beyond those two bugs, a cluster of general, independently-researched arguments held up: Next.js’s caching model hides observability that Redis gives you natively, React SSR does real synchronous work Node’s event loop has to pay for, server-rendered templating doesn’t need a build step, and Next.js’s CVE surface is measurably larger than a minimal Express stack’s.
  • Alpine.js replaced React for all client-side interactivity, roughly an order of magnitude smaller in bundle size, for a site that never needed a component framework’s rendering model in the first place.
  • Some of this could have been fixed without migrating. Redis-backed caching, event-loop-conscious rendering choices, and disciplined dependency management are all things Next.js can support. Doing all of that properly, though, ends up building close to the same infrastructure the migration arrived at anyway.
  • A content-driven, mostly server-rendered site was never the shape of problem Next.js was built to solve, and the accumulated cost of pretending otherwise eventually outweighed the convenience.

The two bugs that started this

Two specific, documented problems did real work in this decision, not vibes, not a hunch that something felt slow.

The first was social embeds silently breaking on client-side navigation. Instagram, TikTok, and Twitter embeds pulled from a WordPress backend would render correctly on first load, then silently fail after any client-side route change, a documented, still-open pattern in Next.js’s own GitHub history going back to 2019, tied to how dangerouslySetInnerHTMLinteracts with the browser’s refusal to execute scripts inserted that way.

The second was a memory leak from caching an unbounded route space. Its search routes, arbitrary text queries crossed with category and geographic radius, generate an effectively unlimited number of unique URLs. Caching that space with a fixed TTL bounds how long any entry lives, not how many can exist at once, and the site hit exactly the failure mode that’s been documented against Next.js’s cache internals since 2021: memory that grows until something restarts the process.

What held up under actual research

Two documented bugs are a real motivation, but they’re anecdotes, not an argument. The more interesting question was whether the broader case held up once I went and researched it properly, rather than assuming the two problems I’d hit were representative of something larger. Five separate pieces came out of that research, each fact-checked against primary sources rather than taking the migration’s own narrative at face value:

  • Alpine.js replaced React for a specific, common shape of site, content-driven, mostly server-rendered, with small islands of interactivity, at roughly a tenth of the bundle size, while React remains the right tool for apps with genuinely complex client state.
  • React’s server-rendering APIs do real, measurable synchronous work on Node’s single-threaded event loop, documented directly by Expedia’s own engineering team and a live, unresolved React GitHub issue, a cost a plain template engine never incurs because it never builds a component tree to reconcile with the client.
  • Next.js’s caching layer hides information that Redis exposes natively. Hit rate, eviction activity, and memory pressure are a single redis-cli command away when you own the cache directly; an open, years-old Next.js discussion confirms there’s still no built-in way to know whether a given response was cached at all for self-hosted deployments.
  • Server-rendered templating doesn’t need a build step. EJS compiles to a function the first time it renders and never touches a separate compile phase again. Deploying means restarting a process, not shipping the output of a pipeline.
  • Next.js has roughly 11 times more known CVEs than Express, same source, same date, and the severity gap is sharper than the count alone: two of Next.js’s critical vulnerabilities are as severe as web application security gets, including an unauthenticated remote code execution with a maximum CVSS score of 10.0.

None of these five pieces depend on the two bugs above, or on each other. Each stands on its own sourcing.

Where philosophy did real work, not just data

A few things pushed this decision that aren’t cleanly citable the way a CVE count is. These are judgment calls, not research findings.

Next.js’s own feature set increasingly assumes Vercel’s infrastructure underneath it. Incremental Static Regeneration, edge middleware, and image optimization all have a materially better story when Vercel’s own CDN and Data Cache are doing the work they were designed around. Self-hosting doesn’t just lose convenience, it inherits the responsibility for infrastructure the framework assumes someone else is providing. A stack built directly on Express doesn’t have that assumption baked in anywhere, because it was never designed with a specific hosting platform as its implicit target.

There’s also a practical shift in how much abstractions are actually worth. The traditional case for a framework’s boilerplate, routing conventions, and data-fetching patterns is that they save a developer from writing repetitive code by hand. That calculus changes when an LLM can generate correct, idiomatic boilerplate for a well-understood framework in seconds. Express is old, extremely well-documented, and has a large, stable body of public code to draw from, which in direct experience makes AI-assisted development on it more consistently reliable than on a framework whose internals and conventions have shifted across three major caching-model rewrites in as many years. That’s a genuinely new variable in this kind of decision, one that didn’t really exist a few years ago.

Express 5 also closed a real, concrete gap on its own merits. Express’s own documentation confirms that starting with version 5, route handlers and middleware that return a rejected promise automatically forward the error to error-handling middleware, no more wrapping every async route in a try/catch or a manual asyncHandler utility just to avoid an unhandled rejection crashing the process.

What this looks like now

The cutover itself was a hard switch, not a staged rollout, no gradually shifting traffic between two live stacks, no long window running both in parallel. Weeks of focused development and testing against the existing site’s actual behavior came first, with parity treated as a requirement to clear before deployment rather than something to verify after. That’s a real tradeoff: less operational complexity during the transition, more risk concentrated at the moment of the switch, and it held up because the testing phase carried the weight a staged rollout would otherwise have provided.

Without leaning on precise numbers a deleted server can’t be re-measured against, here’s what changed: baseline resource usage is noticeably lower, the process no longer needs periodic restarts to manage memory growth, and deploys are close to instantaneous since there’s no build pipeline between a commit and the code actually running. This stack has also become the default starting point for new projects since, not a one-off fix built for a single site under specific pressure.

Could this have been avoided without migrating?

Yes, most likely. Neither the crash nor the CVEs made migrating the only available path. Next.js provides a documented, if not entirely turnkey, way to point its caching layer at Redis instead of process memory. Careful use of streaming APIs and Suspense boundaries can reduce, though not eliminate, the event-loop cost of server rendering. Dependency discipline and prompt patching apply to any framework’s CVE list.

In practice, doing all of that properly converges on close to the same infrastructure this migration ended up with anyway, a Redis layer you own and can inspect directly, rendering you understand end to end, and a smaller dependency surface to keep patched. Once that infrastructure exists, Next.js stops buying much on top of it for a site with this shape. The bugs made the case loudly. The research made the case carefully. Both point at the same conclusion.

RSS Feed Newsletter
Contact us

Latest Blog Posts