Why we finally left NextJS for TanStack Start
After months of memory leaks, version pins and build bugs, we moved a very large production app from NextJS to TanStack Start on Cloudflare Workers, and here's how I did it without a big bang rewrite.
Back in August I upgraded NextJS to 16.3.0, and over the course of a single evening I watched our production frontend's memory climb steadily until the servers had to restart. I pinned back to the previous preview build and I figured that would be the end of it.
It wasn't.
In my last post I mentioned that I have "at least 1 project that has reached a scale that is unlikely to work on OpenNext". This is that project, and as of October it doesn't run on NextJS at all.
It's now TanStack Start on Cloudflare Workers, and I did the whole migration myself with agents. So what does it actually take to move an app this size off NextJS? Below I'm going to cover how I did it, and what went wrong along the way.
Why leave NextJS?
I've been building with NextJS for years, but this past year it felt like every upgrade came with a new problem I had to work around.
For context, we were running NextJS 16 with Cache Components on ECS, with ElastiCache for caching pages and fetch calls. Nearly all of our cached code used 'use cache: remote', which ended up mattering a lot.
A month after that first leak I tried again with 16.3.4. Memory sat flat for a while, then started growing steadily hour after hour until it went past V8's own heap limit.
This time the cause was completely different. On a use cache hit, NextJS splits the cached stream with tee() and holds on to the second branch, and since nothing ever reads it, everything gets buffered in memory.
As one of my PRs put it, "16.3.0 and 16.3.4 have now each leaked, for different reasons." If you want to see it for yourself I put together a minimal reproduction.
Earlier in the year I'd also hit a build bug in 16.2.0, where Turbopack started putting .. in some asset filenames. Cloudflare's firewall treats that as path traversal, so some of our static uploads started failing with a 403 and I had to pin to 16.1.7. There's a reproduction for that one too.
Add in turning off NextJS's response compression to save CPU, and I ended up with a steady stream of pin and revert PRs over about half a year.
None of these were catastrophic on their own, but if you've ever had to babysit a framework upgrade you'll know how quickly that gets old.
How did I migrate?
The obvious first step for me was OpenNext, and I tried it before TanStack Start. The problem was that it did not work on very large applications, and I couldn't get the main application working because of bundle size issues.
Cloudflare has since made upgrades to what they support, but even then I tried it several times on the main app and only got it running once, in September, and it barely worked. To get that build going I had to remove our Redis backed Cache Components and patch a couple of dependencies, and it was very slow and laggy with server timeouts and just not a great experience, so that PR was closed without merging and OpenNext was a non-starter for us.
I'd already tried TanStack Start on a personal project and found it fast, and it was an enjoyable experience. I also liked the maintainers' approach and the direction they were taking TanStack, and that sealed the deal.
Right after the OpenNext attempt, I moved a smaller internal app to TanStack Start on Workers, keeping all of its page URLs the same. That ended up being a dry run for the main app.
Building a seam
The main app was a different story. With hundreds of files importing from next/*, rather than one giant branch that swaps everything at once, most of the effort went into making the codebase run on both frameworks at the same time.
Every framework specific import (navigation, images, headers, Sentry, Sanity, caching and env) now goes through a thin layer of our own modules. A build time alias points that layer at a NextJS version or a TanStack version, so feature code doesn't know or care which one it's running on.
I moved the existing imports over with a handful of codemods, then added a lint rule that bans next/* anywhere outside that layer so nobody could quietly add new ones.
Each page's data, redirects and metadata also live in one place that both apps call. That meant I could ship both apps from the same branch, while the NextJS build kept behaving exactly like main.
If you're using oxlint for this, there are two gotchas that cost me some time:
- A
!negation in a later pattern group silently disables the includes from earlier groups, so keep everything in one ordered group - A bare pattern matches the last path segment, so banning a package like
server-onlywill also ban any of your own modules that happen to share the name
Proving nothing changed
Before there was a TanStack build to test, I wrote the scripts that would judge it.
The first one turns the NextJS src/app tree into URL patterns and fails if TanStack's route tree is missing any of them. That came out to just over two hundred pages and a couple dozen route handlers, and it doubled as my to-do list.
The second compares a few hundred URLs between production and a candidate, checking status codes, redirects, headers, cookies and SEO tags.
I'd recommend running something like this against production versus production first. I ran it a few times, about half an hour apart, to learn what's normally noisy, and found a page returning a 307 instead of a 301 and a couple of routes that flip between 404 and 200 on their own.
Without that baseline, both of those would have looked like TanStack bugs.
With both apps living side by side, the NextJS build still matched main with no differences across those URLs, and pages only carried a tiny bit of extra JavaScript.
Cutover day
Every build gets uploaded to Workers as a version first, and it doesn't serve any traffic until it's promoted. Before uploading, the pipeline checks the Worker has all the secrets it expects, scans the client bundle for anything that looks like a secret, and fails if the bundle gets too big.
If you're planning on preview URLs, note that Cloudflare doesn't give them to a Worker that implements a Durable Object. I wanted previews on every PR, so our cache tag versions live in D1 instead.
The cutover PR touched about a thousand files and ran over ten thousand tests, and it merged on October 1st, and cutover day felt very smooth. All I really did was switch over DNS, and everything started working exactly like it had been working before.
Caching was working, and the app was rendering reasonably fast, although I did need to make some upgrades over the coming weeks (more on those below), but it was very smooth.
That afternoon I removed the old ECS deploy once the smoke tests passed, then deleted the NextJS app (around 42 thousand lines of it) a couple hours later. Another internal app followed a few days after that, and on October 6th NextJS was gone from the monorepo entirely.
Gotchas
It wasn't all smooth sailing, and these are the ones worth knowing about going in.
Zod error messages
zod marks itself as side effect free, so Vite dropped its locale setup and every validation message zod wrote itself just said "Invalid input" in production. The fix was registering the English locale myself.
Stale tabs
After a deploy, tabs that were opened before it would go looking for JavaScript chunks that no longer existed.
My first fix was a guarded reload. The better fix moves a stale tab onto the new build on its next navigation, unless it's holding unsaved work.
Test coverage
Deleting the NextJS pages also deleted their tests, which left a few behaviours without coverage that I had to list as follow ups.
A TanStack Start security advisory also landed a couple weeks after cutover. Not a big deal, but it's a younger ecosystem and you'll feel that.
Was it worth it?
A big part of it for me was cost. NextJS was costing us somewhere around $500 to $1,000 a month depending on load, and honestly depending on what mistakes had been made that month, and most of that came from server rendering.
As a side note, we never actually paid for observability on top of that, but we were quoted around $1,400 a month just to get basic log querying for the application, which felt like a lot for being able to search our own logs.
Cost wasn't the only thing though. It's honestly let me ship a lot faster, since the local dev server runs so much faster than it used to, and builds are much, much faster too.
You can see it pretty clearly in my own commits. Before the migration I was averaging around 40 commits a week, during it that went up to around 130, and since cutover it's been sitting around 210, with more and more of that work done alongside agents.


I also haven't had a single dependency issue since the switch, and upgrades have been clean and just work, which after the year I'd had with NextJS felt almost too good to be true. Through all of it the application has stayed snappy.
On the performance side, the upgrades in the weeks after cutover were mostly a few small PRs that made our client JavaScript noticeably smaller, mostly by not shipping things the first paint doesn't need, and our login page and homepage both scored better on mobile Lighthouse.
Those were local runs against a production build of the Worker, so take them with a grain of salt. They also shouldn't be read as NextJS versus TanStack, since they came from cleanup PRs I did after NextJS was already gone.
Wrapping up
If you're thinking about a migration like this, my biggest takeaway is that it's not enough to just do a framework switch like going from NextJS to TanStack. You have to make sure your systems are in a place where making these kinds of switches is even possible in the first place.
If I had to boil it down to the things that mattered most, it would be these:
- Start with a clear goal or outcome of what you're hoping to achieve, and test against that.
- Make sure your framework boundaries are clean, so the framework isn't leaking into every corner of your app.
- Have a verification process in place before you start, not halfway through.
- Always prototype before a big migration like this. Don't just assume things will work, assume there's a lot you don't know, and the best way to clear that up is by simply building something first.
Thanks so much for reading, see you next time!