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 61 countries, off Next.js and onto a plain Express and EJS stack. It was meant to fix a specific site. It ended up changing what I reach for by default on every Node project since. What follows is the full account: the specific bugs that made the case, the general research that held up under scrutiny, what four months of server monitoring shows when checked against actual traffic, and the parts of the decision that were closer to philosophy than evidence.
Seven separate pieces came out of working through this, each researched and fact-checked on its own terms rather than leaning on a single before-and-after benchmark.
| Piece | Finding |
|---|---|
| Social embeds break on Next.js route changes | Documented, unresolved since 2019; tied to how the browser handles scripts inserted via dangerouslySetInnerHTML |
| Memory leak on self-hosted search routes | Caching an unbounded route space with a fixed TTL bounds entry lifespan, not entry count |
| Alpine.js replaced React | Roughly a tenth of the bundle size for a site that never needed a component-tree rendering model |
| React SSR blocks Node’s event loop | Documented directly by Expedia’s engineering team and a live, unresolved React GitHub issue |
| Next.js hides cache observability that Redis gives natively | No 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 step | EJS compiles once at first render; deploying means restarting a process, not shipping a pipeline’s output |
| Next.js has 11x more known CVEs than Express | Same source, same date; two of the critical ones are as severe as web application security gets |
Rundown
- Server monitoring cross-referenced against analytics shows CPU falling while traffic rose. Across three months on the new stack, CPU per 100,000 pageviews sits between 3.55 and 3.97 against 11.5 under Next.js, and peak CPU never exceeds 32.67% against 99.97%.
- 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.
- Each problem was individually fixable inside Next.js. But every fix amounts to asking the framework to do less of what it does, which leaves the build step, the hydration cycle, the two-gigabyte dependency tree, and eleven times the known CVEs still in place.
- 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 result became a default rather than a one-off. Every Node project I’ve started since runs on the same foundation, and reaching for a framework is now the choice that needs a reason.
The two bugs that started this
Two specific, documented problems did real work in this decision.
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 research
Two documented bugs are a motivation rather than an argument. The more interesting question was whether the broader case held up once researched 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 substantial 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-clicommand 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.
Each of these five stands on its own sourcing, independent of the two bugs above and of each other.
Where philosophy did real work
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 intuition turns out to have research behind it: coding models fail specifically on version selection rather than difficulty, and a stable API surface is one a model has seen described only one way.
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.
Deploys are close to instantaneous now, since no build pipeline sits between a commit and the running code.
The more durable outcome is that this stopped being about one site. Express and EJS became the starting point for everything I’ve built on Node since, and three sites now run on the same foundation. That inverts the order the decision used to take: a framework is no longer the assumed default that needs no justification, it’s the option that has to earn its place against a stack that has already proven it can carry production traffic on a fraction of the resources. Where an application actually needs what a framework provides, heavy client state, a component-tree rendering model, a real build pipeline, that case can still be made. It just has to be made.
What the monitoring shows
Linode’s server monitoring and Plausible analytics cover the same months, which makes it possible to check CPU against actual traffic rather than assuming it held steady.
| Month | Stack | Pageviews | Avg CPU | Peak CPU |
|---|---|---|---|---|
| March 2026 | Next.js | 285k | 32.77% | 99.97% |
| April 2026 | Migration month | 335k | 27.12% | 48.39% |
| May 2026 | Express + EJS | 350k | 13.88% | 27.58% |
| June 2026 | Express + EJS | 343k | 13.15% | 32.67% |
| July 2026 | Express + EJS | 446k | 15.84% | 25.55% |
Traffic went up while CPU went down. May served 23% more pageviews than March at 58% lower average CPU. July, the highest-traffic month the site has had, served 56% more pageviews than March at 52% lower average CPU.
Three consecutive months on the new stack make the pattern hard to dismiss as a quiet stretch. Expressed as average CPU per 100,000 pageviews, March under Next.js sits at 11.5. May, June, and July come in at 3.97, 3.83, and 3.55, a reduction of roughly two-thirds that barely moves across months whose traffic varies by a hundred thousand pageviews.
The peak column tells the same story more sharply. Under Next.js the server touched 99.97% while serving 285k pageviews. The three Express months peak between 25.55% and 32.67%, none of them within sixty points of that, across traffic running as high as 446k. Whatever produced the March spike, and a production npm run build is CPU-intensive enough to be a candidate, substantially more traffic on the new stack never comes near it.
Inbound bandwidth needs explaining rather than reading at face value. It averaged 1.54 Mb/s in March, then 0.835 Mb/s in May and 0.845 Mb/s in June, before rising to 1.14 Mb/s in July. Very little of that is visitor requests, which are small. Under Next.js, every deploy meant a git pull, an npm install against a project whose dependency tree ran to roughly two gigabytes, and a full rebuild, often several times in a day. An Express deploy is a git pull and a process restart. March’s inbound figure is inflated by build traffic; July’s is higher than May and June because actual traffic grew.
Disk I/O averaged 14.25 blocks/s in March against 8.96 in May, 8.93 in June, and 9.86 in July, with peaks falling from 150.23 to a range of 31 to 48. Process restarts went from 270 to 2, where the 270 were overwhelmingly unplanned and the 2 were deploys.
Memory is the one number that cannot be compared honestly, and the reason matters. Under Next.js, resident memory climbed continuously until the process hit its ceiling and restarted, so any single reading is a point on a sawtooth rather than a steady state. Comparing an arbitrary moment of that climb against a stable Express figure would produce a number that means nothing. What changed is the shape: memory holds flat now instead of climbing toward a restart.
Could this have been avoided without migrating?
Each problem individually, yes. 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. None of those fixes required leaving.
What that framing misses is what the fixes have in common. Every one of them is a way of asking the framework to do less of what it does. Pointing the cache at Redis means switching off framework-managed caching. Cutting the event-loop cost of server rendering means rendering fewer components on the server. Shrinking the CVE surface means removing dependencies. Follow that path to its end and the result is a Next.js application configured to behave as much like a plain server as possible, still carrying the build step, the hydration cycle, the roughly two-gigabyte dependency tree, and eleven times the known CVEs, in exchange for progressively less of what the framework was providing.
That is the mismatch, and it is the actual reason the migration happened. A content-driven site that renders mostly on the server, with small islands of interactivity, is not the shape of problem Next.js’s architecture was built around. A bundler, an image pipeline, a component-tree rendering model, and a framework-managed cache are real answers to real problems, just not to these ones. Configuring that architecture to stay out of the way means paying for it twice: once in the abstraction, again in the work of routing around it.
Removing the layer instead was not a lateral move to equivalent infrastructure. It cut CPU per pageview by roughly two-thirds, brought peak load down from a full-capacity spike to under a third of capacity on higher traffic, ended unplanned restarts, removed the build step from the deploy path, and reduced the known-CVE surface by an order of magnitude. That is what the platform now makes available, which is the broader case for building on it directly: the gaps a framework existed to cover have largely been filled by the language, the browser, and the runtime.
The bugs made the case loudly. The research made the case carefully. The monitoring settled it, and four months later the question on a new project is no longer which framework, but whether one is needed at all.
Related reading
The capability that makes this practical is documented across three pieces: everything vanilla JavaScript has gained since React launched, what the browser platform gained, and what Node.js gained over the same window.