ブログ

Migrating a Production SaaS from Next.js to TanStack Start: LCP Went from 3.8s to 0.8s

Five days, 91 commits, zero downtime: what we gained moving a live SaaS from Next.js to TanStack Start on Cloudflare Workers — and the four production incidents it cost us.

Jul 6, 2026OVOV TeamOVOV Team
Migrating a Production SaaS from Next.js to TanStack Start: LCP Went from 3.8s to 0.8s

In late June we rewrote our SaaS — an AI creative suite with paying subscribers, a credit system, and 12 languages — from Next.js to TanStack Start on Cloudflare Workers. It took five days and 91 commits, with payments live the whole time.

Here's the before/after. Same machine, same network, Chrome DevTools local metrics, taken while both versions were still serving production traffic:

MetricOld (Next.js)New (TanStack Start + Workers)Change
LCP3.84 s0.80 s4.8× faster
CLS0.010.00both fine
INP48 ms48 msunchanged

To be clear about what actually improved: loading. Interaction latency was already fine and stayed exactly the same, so no, the new framework didn't make our buttons magically snappier. We've since shut the old deployment down, so you can't race them yourself anymore; the numbers above are what we captured while both were live.

Warm TTFB on the new site sits around 0.6s from where we measure, cold starts around 1.1s. Getting cold starts down to that number turned out to be half of this story.

Why we moved

Not because Next.js is bad. Because the things we cared about had drifted away from what our setup was giving us:

  • One deploy target. Payments, generation APIs, SSR, static assets — we wanted all of it on Cloudflare behind one deploy command, instead of a small constellation of services that each fail in their own way.
  • Control over the server bundle. This one ended up being decisive. Our TTFB problem (below) was only fixable because we could see, and cut, every byte that ships to the edge runtime.
  • Compile-time i18n. 12 locales, roughly 2,600 strings each. The old app resolved translation keys at runtime, so a missing key meant a white screen in production. Now a missing key is a build error. This single change has probably prevented a dozen incidents already.
  • Typed routing that we don't have to remember. TanStack Router's file routes with typed loaders replaced a pile of convention-based glue that lived mostly in our heads.

The constraints were the interesting part. The production database — 29 tables, real payment and credit records — could not change at all. Sessions had to survive the switch with zero re-logins, which meant keeping the same auth secret and cookie names (better-auth made that workable). And checkout stayed live the entire five days.

Build speed is not page speed

This is the part that might start an argument, so let me be precise about the claim.

Next.js has genuinely improved lately — but look at where. Turbopack, faster dev server startup, cheaper CI builds. Those wins go to developers. What a user experiences is the runtime: how much framework machinery has to load, parse, and run before the page paints. And there the two frameworks make opposite bets. Next.js ships a substantial runtime — the Server Components machinery, several layers of implicit caching, serialization across the server/client boundary. TanStack Start ships a minimal runtime on top of Vite and leaves the rest to you.

You don't have to take our word that this difference is real. Platformatic ran the same app on both frameworks last month — no caching, AWS, ramping to 1,000 req/s (benchmark here; they sell a Node runtime, so season to taste, but they ran all frameworks on the same setup). TanStack Start held ~1,000 req/s at about 13ms average latency. Next.js 15 managed 322 req/s with latencies in the seconds. The Next.js 16 canary — after real runtime work, including an RSC serialization bottleneck that had to be patched in React itself — recovered to 701 req/s at 431ms. Credit where due: that's a six-fold improvement. It's also still thirty times the latency, climbing out of a hole the architecture dug.

So here's the honest version of our take: recent Next.js got much faster to build on, and only started getting faster to open — because the weight is architectural, and a framework that ships more machinery per request has a floor it can't optimize below. Our 3.84s → 0.80s table is one team's data point, but it points the same direction as the public numbers. If your LCP is already great on Next.js, this argument isn't for you. Ours wasn't.

Where the 4.8× actually came from

No single trick, three compounding things.

First, the server bundle went on a diet, because on Workers it has to. Every megabyte of JavaScript a cold isolate parses is user-facing latency, which is a bill Node servers never hand you. Our first post-launch TTFB was an embarrassing 2.8s, and our first diagnosis — "we need edge caching" — was wrong. We had the whole caching plan written up before the evidence killed it: the real cost was cold-start parsing of a bloated SSR bundle. The doc is still in the repo, unmerged, as a reminder.

The actual fixes were subtraction. An icon library was dragging its entire catalog into the server bundle; replacing dynamic icon lookup with a static registry of just the icons we render cut 3.27 MB in one commit. A database driver we never use on Workers (libsql, part of a multi-DB abstraction) was bundled anyway; stubbing it at build time removed another chunk. Total upload now sits around 6.2 MB gzipped against Workers' 10 MiB hard cap — and that cap is a real product constraint for us, because our chat feature's markdown/streaming dependency chain has to stay lazy-loaded and out of SSR forever.

Second, SSR at the edge with nothing between the user and the render. The new stack renders on Cloudflare's edge, Hyperdrive pools the database connection, and there's no origin hop.

Third, less JavaScript doing less work before first paint. Compile-time message resolution means the client isn't parsing locale dictionaries before it draws anything.

What it cost: four production incidents in week one

Every one of these traces back to a physical constraint of the Workers runtime that a long-running Node server simply doesn't have.

The self-deadlock we shipped ourselves. Workers cap concurrent TCP connections per request at single digits, so we scoped one DB connection per request via AsyncLocalStorage. Standard practice, and it flushes out N+1 queries fast — on Workers an N+1 isn't slow, it's fatal. Then a code path called a helper inside a transaction, and the helper called db() again. On a normal pool that's sloppy but survivable; the helper just grabs a second connection. On a request-scoped single connection, the helper waits for the connection the transaction is holding, and the transaction waits for the helper. Every generation request on the site hung. Silently. No error, no timeout. What broke it open was one row in pg_stat_activity: a transaction idle-in-progress, and a query waiting on the same connection. House rule since then: inside a transaction callback you pass tx down, always, and a bare db() inside a transaction blocks the review.

Fire-and-forget code that quietly does nothing. On Workers the event loop freezes the moment your response returns. Un-awaited promises don't fail, they just stop existing. We had post-generation bookkeeping that worked perfectly in vite dev and did absolutely nothing in production. ctx.waitUntil() exists, but inside framework server functions you often don't have the execution context in hand. So the rule became: await everything. If work is genuinely deferrable it goes to a queue or a cron, never a dangling promise.

The database path that hangs instead of failing. Connecting straight from Workers to Supabase's pooler TCP-hangs intermittently — clean handshake, then silence. That's worse than an error, because everything upstream times out late or never. Cloudflare Hyperdrive in front of the database isn't an optimization on this stack, it's required infrastructure. We wrapped the setup in an idempotent script so it can't exist half-configured.

The migration miss a human being caught. Our old app granted signup bonus credits through better-auth's databaseHooks. That hook lives in configuration, not in any route file, so our page-by-page, endpoint-by-endpoint migration checklist never touched it — and new users silently got zero credits. We found out when a directory reviewer signed up to evaluate the product and told us they'd received nothing. Hook restored, affected accounts backfilled, and the real lesson written down: when you migrate frameworks, inventory the side effects that live in config. Auth hooks, middleware, scheduled jobs, webhook registrations. Route-level checklists miss exactly the things that don't live in routes.

On using an AI agent for most of this

Full disclosure: most of the migration — the port itself, the i18n conversion of about 31,000 strings, and the incident fixes above — was done with Claude Code, with a human reviewing diffs and controlling deploys. What made that workable wasn't the code generation. It was the guardrails around it: a script that physically blocks migration commands against the production database, typecheck and build as non-negotiable gates, deploys always manual, and verifying against production — curl the real pages, read the real headers — before anything gets called done. The deadlock above was written by the agent. It was also found and fixed by the agent, from pg_stat_activity output. Make of that what you will.

Would we do it again?

Yes, and faster the second time. For a team our size, the complexity we were carrying had quietly grown past what it paid back, and the numbers are hard to argue with: 4.8× faster LCP, one deploy target, and a 12-locale build that catches missing translation keys before they ship.

But go in with clear eyes. Workers is not "Node with better cold starts." It's a different physics: connections are scarce, the event loop dies with the response, and every byte of your bundle sits on the latency-critical path. The speed is real. So is the tuition.

The app is OVOV — an AI creative suite for image, video, music, and voice — if you want to poke at the result.