← Blog

Why I migrated to TanStack Start

Moving a React app off Next.js and onto TanStack Start, Vite, and Cloudflare Workers — what got simpler, what got more explicit, and how to drive a big migration with codemods instead of a rewrite.

Jun 25, 2026·10 min read·Engineering
Why I migrated to TanStack Start

I moved this site off Next.js and onto TanStack Start. Not because Next.js is bad — it's a remarkable piece of engineering — but because I kept fighting the framework to understand what it was doing on my behalf. Caching that I couldn't see. A bundler I couldn't reason about. An RSC model that was powerful right up until the moment it wasn't, and then opaque.

TanStack Start is the opposite bet. It's a thin, router-first layer over Vite. Almost everything is explicit and typed, and the parts that used to feel like magic are now just functions you call. Here's what actually changed, and how I drove the migration without a from-scratch rewrite.

Caching you can actually see

This was the big one. Next.js caching is a layered system, and the layers interact in ways that depend on the shape of your code. Whether a fetch is cached can hinge on whether it ran before or after a request-time API. On top of that sit route-segment config (fetchCache, revalidate), tag-based invalidation (revalidateTag, revalidatePath), and now the "use cache" directive with its own cache profiles. Each layer is reasonable. Together they're a lot to hold in your head when something serves stale data and you don't know which layer did it.

TanStack Start has two caches, and both are libraries with documented, boring semantics.

The router loader cache is a stale-while-revalidate cache keyed by the resolved pathname plus whatever you declare in loaderDeps. The knobs are right there in your route options:

  • staleTime defaults to 0 — data is stale immediately, so each navigation renders the cached value and revalidates in the background. Classic SWR.
  • defaultPreloadStaleTime defaults to 30s, so hovering a link twice doesn't refetch twice.
  • gcTime defaults to 30 minutes before unused loader data is collected.
  • router.invalidate() is your blunt instrument when you need everything to reload.

When I need shared, cross-route caching with mutations and optimistic updates, I reach for TanStack Query — wire the router's loader events into the Query cache, set the preload stale time to zero, and let Query own freshness. Its defaults are predictable too: staleTime: 0, structural sharing on by default so unchanged slices of a response keep their object identity and don't trigger re-renders.

A single clean translucent cache layer beside a tangled leaning stack of overlapping cache layers

The mental shift: caching moved from an emergent property of the framework to a value I configure on a loader. I can read a route file and know its caching behavior. That's the whole pitch.

A Vite dev server and a route tree that types itself

The day-to-day developer experience is just a modern Vite app. Native ESM in dev, fast cold starts, honest HMR. The build pipeline is a list of plugins in vite.config.ts — Cloudflare, Tailwind, Start, React — each one a concern you can see and reorder, instead of framework internals you configure through documented knobs and hope.

Routing is file-based under src/routes, and tsr generate (or the router Vite plugin) compiles it into a fully typed routeTree.gen.ts. That generated tree is the contract for the whole app: path params, search params, and loader data are all typed end to end. Search params especially are a revelation — they're parsed and validated through the router, so pagination, filters, and tabs live as typed URL state instead of useState you manually sync to the querystring. Treat routeTree.gen.ts as read-only and forget it exists.

Server startup and functions, as plain functions

Next.js gives you server actions, route handlers, and middleware.ts, all woven into the App Router runtime. TanStack Start unbundles that into a few explicit primitives:

  • createServerFn defines a same-origin RPC endpoint you can call from a loader, a component, a hook, or another server function. Start handles serialization, redirects, and notFound across the boundary, and verifies requests via Fetch Metadata headers. For genuinely public, cross-origin endpoints you use server routes instead.
  • createStart in src/start.ts is the one explicit place to hook the server lifecycle. You compose global requestMiddleware (runs on every SSR request, server route, and server function) and functionMiddleware (every server-function call) in a deterministic, dependency-first order. It's also where you set a default fetch for all server functions — global interceptors, retries, tracing.

One detail I appreciate: client context isn't shipped to the server unless you opt in by passing sendContext through next(). And CSRF protection is installed automatically — unless you add your own src/start.ts, in which case you opt back in explicitly with createCsrfMiddleware. The defaults are safe; the escape hatches are visible.

Cloudflare without an adapter

This site runs on Cloudflare Workers, and that's where the architectural difference is sharpest.

Next.js reaches Workers through OpenNext's adapter: it runs the Next build, then transforms the Node-targeted output into something Workers can run. It supports an impressive surface — App Router, ISR, middleware, PPR — but it's a build-and-transform step bolted on after the fact, and you're watching the compressed bundle against the Worker size limit (3 MiB free, 10 MiB paid).

TanStack Start has no transform step. The @cloudflare/vite-plugin makes Vite emit Worker-shaped output directly, so the same pipeline I run in dev produces the artifact I deploy with wrangler deploy. Cloudflare bindings (R2, KV, AI, queues) come in through a typed env, with wrangler types generating the types. The dev/prod gap basically closes.

A lightweight app artifact dropping onto a global edge network of glowing nodes with streaming lines radiating outward

Caching gets cleaner here too. Because the router cache is in-memory and scoped to the Worker lifecycle, long-lived caching is just Cloudflare's edge cache plus KV/R2 — application SWR and infrastructure caching stay in separate, legible boxes. Static prerendering covers the routes that can be static; server functions cover the rest.

RSC, but the magic is a function call

TanStack Start does support React Server Components — you add @vitejs/plugin-rsc, flip rsc.enabled in the Start plugin, and you get two helpers: renderServerComponent for inlining a server-rendered element into a loader, and createCompositeComponent for a <CompositeComponent src={...} /> that supports slots. There's even an ssr: 'data-only' mode for when the component needs browser APIs but its data can come from an RSC.

The difference from Next.js isn't capability, it's placement. In the App Router, RSC is the default and caching is entangled with it — what lands in the static shell versus what streams depends on RSC semantics you learn by experience. In Start, RSC is opt-in and local. You decide which loader returns a server component; the router still owns routing and data. You can adopt it for one performance-critical corner without restructuring the app. Same power, far less spooky action at a distance.

The one thing to know: no image optimization

The honest gap: TanStack Start has no next/image. There's no built-in optimizer, no automatic responsive srcset, none of it. If you lean on next/image heavily, budget for this.

I use unpic to fill it. It's a provider-agnostic image component — one API across ~10 frameworks and ~26 image CDNs — that builds the right transformation URLs for your provider. On Cloudflare that pairs naturally with Cloudflare Images: unpic constructs the URLs, Cloudflare resizes and caches at the edge. You lose the zero-config convenience and gain provider portability. For this site it was a fair trade; for an image-heavy product it's a real cost to weigh.

How to drive a big migration

The trap is treating this as a rewrite. It isn't — it's a mechanical mapping, and most of it is automatable. The concepts line up almost one-to-one:

Next.js TanStack Start
app/ / pages/ files src/routes/ + generated route tree
getServerSideProps / App Router fetch route loader + createServerFn
Server actions createServerFn
API route handlers server routes
middleware.ts requestMiddleware in src/start.ts
next/link, useRouter <Link>, router hooks
next/image unpic

Because the mapping is structural, you can automate it with ts-morph and codemods instead of hand-editing hundreds of files. ts-morph gives you a real TypeScript AST to query and rewrite: find every next/link import and swap it for the router's <Link>, rewrite useRouter call sites, lift getServerSideProps bodies into loaders, replace next/image with unpic and remap the props. The semantic conversions — revalidate values into staleTime/gcTime — are the ones to do by hand, but they're a small fraction of the work once the mechanical 80% is scripted.

An abstract syntax tree of glowing nodes being rewritten by a precise robotic arm

And you don't have to go big-bang. Router and Query are just libraries — you can adopt them inside a Next.js app first, replacing next/router and ad-hoc fetching with typed routes and explicit caches, so the eventual move to Start changes the server and deploy story without touching how you route and fetch. Or carve off a section — a marketing site, an admin panel — rebuild it in Start, and split traffic at the edge while you migrate the rest.

Where I landed

A few months in, the thing I keep coming back to is that explicit beats implicit when you're the one on call. Start's caching, server lifecycle, and build pipeline are all things I can read in a file, not behaviors I have to infer from how the framework happened to interpret my code. And running one Vite pipeline from dev all the way to the edge quietly removed a whole class of "works locally, breaks on Workers" bugs, because there's no longer a transform step in between where they could hide.

The migration itself was far less work than I feared, because the Next.js to Start mapping is structural — codemods handled the repetitive 80% and I spent my actual attention on the semantic 20%. The one real cost was losing built-in image optimization; unpic closes most of that gap, but it's the trade I'd want you to go in knowing about.

I'm not here to tell you Next.js is wrong. For plenty of teams its integration is exactly the trade they want. But if you've been fighting the framework to understand it — if you want the seams visible — TanStack Start is a genuinely different, calmer bet. This whole site is the proof I needed.

Roy van Kaathoven
Roy van Kaathoven
Technical founder energy, freelance availability