Migrate Next.js to TanStack Start: Practical Guide with Real Examples
How to migrate a Next.js app to TanStack Start: the two-PR strategy, route migration, replacing next/image, and setting up Nitro, with real production lessons from Railway and Inngest.
On this page
Railway moved 200+ routes off Next.js onto TanStack Start in two pull requests. Their builds dropped from 10+ minutes to under 2 minutes. Inngest made a similar move shortly after. TanStack Start hit v1.0 in March 2026, and a clear pattern emerged: teams running client-heavy, real-time apps are migrating because Next.js's server-first App Router doesn't add value for their use case.
The common thread is friction. These teams fight the framework more than they benefit from it. Server Components add complexity to dashboards that are 95% client-side. Webpack builds crawl when Vite would finish in seconds. Vercel-specific optimizations don't help when you deploy to Railway, Fly, or bare Docker.
This guide combines official TanStack docs with practical lessons from production migrations. It covers the two-PR strategy Railway used, route conversion patterns, and the trade-offs you should weigh before committing. TanStack Start pairs naturally with a Hono-based API layer, so if your backend is already decoupled, the move is even smoother.
Is This Migration Right for Your App?
| Good fit for TanStack Start | Stay on Next.js | |
|---|---|---|
| App type | Client-heavy SPAs and dashboards | SEO-heavy marketing sites |
| Real-time needs | Websocket and real-time apps | Heavy ISR/ISG usage |
| Build tooling | Teams wanting Vite speed | Deep Vercel ecosystem integration |
| Use case | Internal tools with auth | Reliance on next-seo, next-sitemap ecosystem |
| Architecture | Apps not using Server Components | Apps leveraging RSC heavily |
Railway's engineering team summarized it well: their app is "overwhelmingly client-side, a rich stateful interface, websockets everywhere." Server Components added nothing. The App Router's file conventions felt like overhead for routes that immediately hydrate into fully interactive client code.
If that description matches your app, keep reading. If your site depends on static generation, edge middleware, or server-side rendering for SEO on every page, Next.js is still the better tool.
When the Honest Answer Is No
A framework migration is a cost with no user-visible payoff. Nobody opens your app and notices that the router changed. The only reasons that justify the spend are compounding ones: build minutes you pay on every commit, a rendering model you keep fighting, or a hosting arrangement you cannot escape.
Skip the migration if your app is mostly static content or marketing pages. Static generation, incremental revalidation, and the crawler behaviour around them are genuinely good in Next.js, and you would be rebuilding all of it by hand for no ranking benefit.
Skip it if Server Components are load-bearing in your codebase. If you stream data from async components, keep heavy dependencies off the client with server-only imports, or rely on partial prerendering, you are using the exact features TanStack Start deliberately does not replicate.
Skip it if your team is small and mid-roadmap. A framework swap on a five-person team is weeks of work plus a long tail of unfamiliar bugs in code you thought you understood. A slow build is annoying. Missing a quarter is worse.
Skip it if your only complaint is your hosting bill. You can self-host Next.js on a container platform today and keep every other decision intact. Price that option honestly before you commit to a rewrite.
Setting Up TanStack Start
For a greenfield project, the scaffolding command gets you running in seconds:
npm create @tanstack/start@latest my-app
cd my-app
npm install
npm run devFor an existing Vite project (or when migrating from Next.js), install the packages manually:
npm install @tanstack/react-start @tanstack/react-router vinxi
npm install -D @vitejs/plugin-react viteConfigure Vite with the TanStack Start plugin and Nitro server settings:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackStart } from "@tanstack/react-start/plugin";
export default defineConfig({
plugins: [
tanstackStart({
react: react(),
server: {
preset: "node-server", // or "cloudflare-pages", "vercel", etc.
routeRules: {
"/api/**": { cors: true },
},
},
}),
],
});{
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node .output/server/index.mjs"
}
}The Two-PR Migration Strategy
Railway's approach splits the migration into two independent, reviewable pull requests. Each PR ships on its own. If something breaks, you know exactly which change caused it.
- 1
PR 1: Decouple from Next.js
Remove every Next.js-specific import and replace it with a framework-agnostic alternative. This PR ships independently and your app continues running on Next.js, just without any proprietary lock-in.
next/image→@unpic/reactor a plain<img>withloading="lazy"next/head→react-helmet-asyncor direct<meta>tags in your layoutnext/router(Pages Router) → a thin wrapper aroundwindow.locationor a shared navigation abstractionnext/link→ plain<a>tags (the prefetching loss is acceptable for most apps)next/dynamic→React.lazy+Suspense
- 2
PR 2: Swap the Framework
With zero Next.js-specific code remaining, the framework swap becomes mechanical. Convert page files to TanStack Router file-based routes, add the Vite config, and update build scripts.
- Convert
app/orpages/directory structure to TanStack Router's route tree (see the route migration section below) - Add
vite.config.tswith the TanStack Start plugin - Replace
next.config.jsredirects and headers with NitrorouteRules - Update
package.jsonscripts fromnext dev/next buildtovite dev/vite build - Update deployment config (Dockerfile, CI pipeline, hosting platform settings)
- Update your CI workflow to use the new build commands
- Convert
Running Both Frameworks During the Transition
The two-PR strategy assumes you can convert every route in one sitting. That works for an app with thirty routes and a team that can pause feature work for a week. It does not work for an app with three hundred routes, and it does not work when the migration has to happen alongside a roadmap.
The alternative is to run both apps at once and shift traffic across gradually. A reverse proxy sits in front of both deployments and decides, per path prefix, which app answers the request. You migrate a section at a time, ship it, and move the prefix.
# Already migrated: served by the TanStack Start deployment
location /app/ {
proxy_pass http://tanstack-start:3000;
}
# Everything else still answered by the existing Next.js deployment
location / {
proxy_pass http://nextjs:3000;
}Three things have to be true before this works. Sessions must be shared, which usually means both apps read the same cookie on the same parent domain and validate it against the same session store. Shared UI has to be extracted into a package both apps import, or you accept temporary duplication. And the boundary between the two apps has to sit where a full page load is acceptable, because navigation across it is a hard reload, not a client-side transition.
Routing logic that currently lives in Next.js middleware has to move up into the proxy, since middleware only runs for requests the Next.js app still owns. Moving Next.js middleware logic into a proxy layer walks through that shift in detail, and the reasoning applies directly here.
Be honest about the cost of this mode: two builds, two deploy pipelines, two dependency trees, and two places to fix any bug that spans both. Set a deadline for decommissioning the old app when you start, and treat a stalled parallel run as a failed migration rather than a stable architecture.
Migrating Routes
TanStack Router uses a $ prefix for dynamic segments instead of Next.js's [bracket] syntax. The file structure maps closely, but data loading and layouts work differently.
Dynamic Routes
Next.js uses bracket notation for dynamic segments. TanStack Router uses a dollar-sign prefix:
// Next.js App Router
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
return <article><h1>{post.title}</h1></article>;
}// TanStack Start
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/posts/$slug")({
loader: async ({ params }) => {
return { post: await getPost(params.slug) };
},
component: PostPage,
});
function PostPage() {
const { post } = Route.useLoaderData();
return <article><h1>{post.title}</h1></article>;
}Data Loading
Next.js App Router uses async Server Components or getServerSideProps (Pages Router). TanStack Start uses a loader function on the route definition. The loader runs before the component renders and its return value is available via useLoaderData().
export const Route = createFileRoute("/dashboard")({
loader: async ({ context }) => {
const user = await fetchUser(context.auth.userId);
if (!user) throw redirect({ to: "/login" });
const [projects, notifications] = await Promise.all([
fetchProjects(user.id),
fetchNotifications(user.id),
]);
return { user, projects, notifications };
},
component: Dashboard,
});Nested Layouts
Next.js uses co-located layout.tsx files. TanStack Router uses parent route files that render an <Outlet /> for child content:
// Next.js layout
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar />
<main>{children}</main>
</div>
);
}// TanStack Start parent route (acts as layout)
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard")({
component: DashboardLayout,
});
function DashboardLayout() {
return (
<div className="flex">
<Sidebar />
<main>
<Outlet />
</main>
</div>
);
}Typed Routes and Search Params
Type safety is TanStack Router's headline feature and the clearest thing you gain from this migration. Route paths, path params, and search params are all inferred from a generated route tree, so a link to a route that does not exist is a compile error instead of a 404 you find in production.
In Next.js, a link target is just a string. Nothing checks that the path exists or that you interpolated the params it needs. Rename a directory under app/ and every link pointing at it breaks silently until someone clicks it.
import { Link } from "@tanstack/react-router";
// "to" is checked against the generated route tree.
// "params" is required because /posts/$slug declares a param,
// and its shape is inferred, not asserted.
<Link to="/posts/$slug" params={{ slug: post.slug }}>
{post.title}
</Link>;Search params get the same treatment, and this is where the gap with Next.js is widest. A route declares a validateSearch function that parses the raw query object into a typed shape, and components read it back with a typed hook. Invalid or missing values are handled once, at the route, instead of being re-parsed and re-defaulted in every component.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/search")({
validateSearch: (search: Record<string, unknown>) => ({
q: typeof search.q === "string" ? search.q : "",
page: Number(search.page ?? 1) || 1,
}),
component: SearchPage,
});
function SearchPage() {
// Typed as { q: string; page: number }
const { q, page } = Route.useSearch();
return <Results query={q} page={page} />;
}Schema library adapters exist for validators like Zod and Valibot if you prefer to declare the shape once and reuse it, but a plain function works and adds no dependency.
The cost of all this typing is a code generation step. The router writes a route tree file that has to stay in sync with your route files, so it runs in dev, in CI, and before every build. On very large route trees, editor responsiveness can suffer because the inferred types are genuinely large. Both are real, and both are usually worth it.
What Actually Breaks During Route Conversion
Most of the conversion is a rename. A handful of App Router conventions have no one-to-one counterpart, and those are where migration estimates go wrong. Map them before you start so nothing surprises you halfway through.
| Next.js App Router | TanStack Router | What to watch for |
|---|---|---|
| app/posts/[slug]/page.tsx | routes/posts/$slug.tsx | Straight rename. Params read from the route, not a props object. |
| app/docs/[...rest]/page.tsx | routes/docs/$.tsx | Splat routes capture the remaining path segments rather than an array prop. |
| app/(marketing)/page.tsx | Pathless layout route (underscore prefix) | Grouping still works, but the naming convention differs and nesting rules are stricter. |
| layout.tsx | Parent route rendering <Outlet /> | A layout becomes a real route, so it can also have a loader and a beforeLoad. |
| loading.tsx | pendingComponent route option | Configured per route instead of by file, with an explicit delay threshold. |
| error.tsx | errorComponent route option | Same idea, different location. Reset behaviour is manual. |
| not-found.tsx | notFoundComponent route option | Can be set at the root and overridden per route. |
| Parallel and intercepting routes | No direct equivalent | Rebuild as modal routes or layout state. Budget real time if you use them. |
Anything you built around a Next.js-specific rendering quirk also needs rechecking. A no-flash dark mode, for example, depends on a blocking inline script placed before hydration, and that script moves into your root route document shell instead of the Next.js root layout. Our guide on avoiding the dark mode flash in Next.js explains the mechanism you need to reproduce.
The other reliable source of breakage is navigation hooks. Every call to a Next.js router hook, every read of the current pathname, and every search param read has a TanStack equivalent, but the shapes differ. Grep for them before the swap rather than discovering them one failing page at a time.
Data Loading, RSC, and Server Functions
This is the deepest difference between the two frameworks, and the one that decides whether the migration is mechanical or a rewrite. Next.js gives you a server-first component model with an opinionated caching layer built into fetch. TanStack Start gives you client components, route loaders, and explicit remote procedure calls.
There is no Server Component equivalent in TanStack Start. If your components fetch data by being async and awaiting inside the render, that pattern does not survive the move. The data fetch goes up into the route loader and the component reads the result. For a dashboard that hydrates immediately anyway, this is a small change. For a content site built around streaming server output, it is a redesign.
fetch Caching Versus Loaders and TanStack Query
Next.js patches the global fetch to deduplicate and cache requests, with per-call options controlling revalidation. The defaults have changed between major versions, which is precisely the complaint many teams have: caching behaviour is implicit and version-dependent.
TanStack Start does not patch fetch. Nothing is cached unless you say so. Route loaders have their own staleness settings, so a loader result can be reused across navigations for a configured window and refetched in the background afterwards. Beyond that, caching is a library decision, and in practice that library is TanStack Query.
The pattern that works best is to combine them. The loader primes the query cache so server rendering and the first paint have data, and the component subscribes to the same query so refetching, mutations, and invalidation behave normally afterwards.
export const Route = createFileRoute("/projects")({
// The router context carries the QueryClient, set up at the root route.
loader: ({ context }) =>
context.queryClient.ensureQueryData(projectsQueryOptions()),
component: Projects,
});
function Projects() {
// Reads the entry the loader already primed, then stays live.
const { data } = useSuspenseQuery(projectsQueryOptions());
return <ProjectList projects={data} />;
}Server Actions Become Server Functions
Next.js server actions are async functions marked with a directive that the framework turns into a POST endpoint, wired into form submissions and its own cache invalidation helpers. TanStack Start's equivalent is the server function, created with createServerFn. The concept is the same: code that only ever runs on the server, callable from the client with types preserved across the boundary.
The differences are in what the framework does for you afterwards. There is no revalidatePath or tag-based cache invalidation to call, because there is no framework-managed cache to invalidate. You invalidate the router or the query cache yourself, which is more code and considerably easier to reason about.
import { createServerFn } from "@tanstack/react-start";
export const createProject = createServerFn({ method: "POST" })
.handler(async ({ data }) => {
// Server only. Database clients and secrets are safe in here
// and are never bundled into the client build.
return db.project.create({ data });
});Environment variables change shape too. The NEXT_PUBLIC_ convention disappears and Vite's prefixed import.meta.env variables take over, with the same build-time inlining behaviour and the same risk of leaking a secret into the client bundle if you prefix the wrong one. Our breakdown of Next.js environment variables covers that build-time versus runtime distinction, and the mental model transfers directly to Vite.
Do the audit during the swap: list every variable, mark each one server-only or public, and confirm after the first build that no server-only value appears in the client output. Grep the built assets for a known secret value. It takes a minute and catches the one mistake that matters.
Replacing next/image
The next/image component does two things: lazy loading and image optimization (resizing, format conversion, CDN caching). You need to replace both. Two solid options:
Option 1: Edge Image CDN with Plain img Tags
If you deploy behind Cloudflare, Fastly, or any CDN with image transformation, use a plain <img> tag with the CDN's URL pattern. The CDN handles resizing and format negotiation (WebP/AVIF).
interface ImageProps {
src: string;
alt: string;
width: number;
height: number;
className?: string;
}
export function OptimizedImage({ src, alt, width, height, className }: ImageProps) {
// Cloudflare Image Resizing URL format
const optimizedSrc = `/cdn-cgi/image/width=${width},format=auto/${src}`;
return (
<img
src={optimizedSrc}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
className={className}
/>
);
}Option 2: @unpic/react
@unpic/react is a framework-agnostic image component that generates responsive srcset attributes and handles lazy loading. It works with any image CDN.
npm install @unpic/reactimport { Image } from "@unpic/react";
export function Hero() {
return (
<Image
src="https://cdn.example.com/hero.jpg"
alt="Dashboard overview"
width={1200}
height={630}
priority // Skip lazy loading for above-the-fold images
/>
);
}Setting Up Nitro
TanStack Start uses Nitro as its server layer. Nitro handles redirects, security headers, caching rules, and deployment adapters. Railway reported consolidating "500+ redirects, security headers, and caching rules into one place" after the migration.
Here is a typical conversion from Next.js config to Nitro route rules. Nitro's routeRules consolidate caching configuration into a single, declarative format.
// Next.js redirects and headers
module.exports = {
async redirects() {
return [
{ source: "/old-path", destination: "/new-path", permanent: true },
{ source: "/docs/:slug", destination: "/guides/:slug", permanent: true },
];
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
],
},
{
source: "/api/(.*)",
headers: [
{ key: "Cache-Control", value: "no-store" },
],
},
];
},
};import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackStart } from "@tanstack/react-start/plugin";
export default defineConfig({
plugins: [
tanstackStart({
react: react(),
server: {
preset: "node-server",
routeRules: {
// Redirects
"/old-path": { redirect: { to: "/new-path", statusCode: 301 } },
"/docs/**": { redirect: { to: "/guides/**", statusCode: 301 } },
// Security headers (all routes)
"/**": {
headers: {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
},
},
// API: no caching
"/api/**": {
headers: { "Cache-Control": "no-store" },
cors: true,
},
// Static assets: aggressive caching
"/assets/**": {
headers: { "Cache-Control": "public, max-age=31536000, immutable" },
},
},
},
}),
],
});Middleware and Auth Patterns
Next.js concentrates request-time logic in a single middleware file that runs before rendering for every matching path. Auth redirects, locale detection, A/B bucketing, and header rewriting all end up there, which makes it powerful and also makes it the file nobody wants to touch.
TanStack Start splits that work across three places, and knowing which one to use is most of the migration effort for auth.
- Route rules handle anything static: redirects, security headers, CORS, and cache directives. No code, no request handler, just config.
- `beforeLoad` on a route handles gating. It runs before the loader, it can throw a redirect, and it can add values to the context that child routes read.
- Server function and request middleware handles anything that must be enforced rather than merely presented, because it runs only on the server.
The common auth setup is a pathless layout route that every protected page nests under. The gate is written once, and the type system enforces that protected routes have an authenticated user in context.
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context, location }) => {
if (!context.auth?.user) {
throw redirect({
to: "/login",
search: { redirect: location.href },
});
}
},
});That warning applies to Next.js middleware too, but the failure mode is quieter there because middleware always runs on the server. Moving the same logic into a route hook that also runs client-side is exactly the kind of change that turns a working auth model into a broken one without a single test failing.
One practical consequence: your session lookup now happens in two contexts. Write a single function that resolves the current user from a request, call it from your root route context on the server, and call it again inside every server function. Duplicated verification is the point, not a smell.
What You Give Up
Every migration has trade-offs. Be honest about what you lose before committing:
| What you lose | Mitigation | Impact |
|---|---|---|
| Built-in image optimization | Use @unpic/react or a CDN with image transforms (Cloudflare, Imgix, Cloudinary) | Low if you already use a CDN |
| next-seo and next-sitemap ecosystem | Write meta tags directly or use react-helmet-async. Generate sitemaps with a build script. | Medium (one-time setup cost) |
| Framework maturity (10+ years of Next.js) | TanStack Router is mature and battle-tested. TanStack Start (the server layer) is newer, v1.0 since March 2026. | Medium for early adopters |
| Vercel-specific features (Edge Middleware, ISR) | Nitro presets cover most hosting platforms. ISR requires a different caching strategy. | High if you depend on these features |
| Larger community and Stack Overflow answers | TanStack Discord is active. Docs are thorough. Fewer answers exist for Start-specific issues. | Low to medium |
CI/CD Changes
Your build commands and output directories change. Update your CI pipeline accordingly.
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build # runs "next build"
# Output in .next/ directoryjobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build # runs "vite build"
# Output in .output/ directory
- name: Start server (or deploy)
run: node .output/server/index.mjsEdge and Serverless Targets
Deploying to a workers runtime rather than a Node server changes the rules on both frameworks in the same way: no Node built-ins, Web APIs only, a hard CPU budget per request, and a bundle size ceiling. What changes is where you configure it.
In Next.js you opt individual routes into the edge runtime and rely on an adapter to package the output. In TanStack Start you select a deployment preset and the server build targets that platform, so the choice is made once for the whole app rather than per route.
That is simpler, and also less flexible. Mixed deployments, where a handful of latency-sensitive routes run at the edge and everything else runs on a Node server, are natural in Next.js and awkward in TanStack Start. If you rely on that split today, prototype it before committing.
Testing Through the Migration
The reason PR 1 exists is that framework-agnostic components have framework-agnostic tests. Component tests written against plain props and plain DOM keep passing across the swap. Tests that mock Next.js navigation modules do not, and you will rewrite each one.
Router-dependent components need a router in the test, and TanStack Router supports this directly with an in-memory history. You build a small test router, render your component inside it, and assert as normal. Write that helper once and reuse it across the suite rather than reconstructing it per file.
End-to-end tests are the real safety net here, because they are the only layer that does not care which framework serves the response. A Playwright suite written against the Next.js app should pass unchanged against the TanStack Start app. If it does not, you found a genuine behavioural difference, which is exactly what you want the suite to tell you.
import { test, expect } from "@playwright/test";
// Same spec file runs against both deployments during a parallel migration.
const routes = ["/", "/pricing", "/dashboard", "/posts/hello-world"];
for (const path of routes) {
test(`${path} renders`, async ({ page }) => {
const res = await page.goto(path);
expect(res?.status()).toBe(200);
await expect(page.locator("h1")).toBeVisible();
});
}For anything with organic traffic, add a URL parity check on top of the functional tests. Crawl your sitemap before the migration and store the status code, canonical URL, and title for every page. Replay that list against the new build and diff it. Silent 404s and dropped canonicals are the most expensive mistakes a migration can ship, and they are trivially preventable.
- Snapshot every sitemap URL with its status code, title, and canonical before you start
- Replace navigation-module mocks with an in-memory test router helper
- Run the existing end-to-end suite unchanged against the new build
- Verify auth redirects with cookies disabled and with an expired session
- Grep the client bundle for a known server-only secret value
- Compare Core Web Vitals on the three highest-traffic pages before and after
- Confirm redirects converted to route rules still return the original status codes
So Should You Migrate?
The migration pays off when the framework is actively costing you something measurable and the thing it costs you is not going away. It does not pay off as a preference, and it never pays off as a rewrite of working code.
| Your situation | Verdict | Reasoning |
|---|---|---|
| Client-heavy dashboard, websockets, no Server Components | Strong yes | This is the case TanStack Start is built for, and the one every public migration report describes. |
| Build times measured in double-digit minutes | Probably yes | Vite build performance is the most consistently reported gain, and it compounds on every commit. |
| Type-unsafe routing causing real production bugs | Worth evaluating | Typed routes are a genuine improvement, but prototype one section first to price the codegen step. |
| Content site relying on static generation and revalidation | No | You would rebuild working infrastructure by hand for no reader-facing benefit. |
| Heavy Server Component usage and streaming | No | There is no equivalent. This is a redesign, not a migration. |
| Small team mid-roadmap, complaint is mostly aesthetic | No | The cost is weeks plus a tail of unfamiliar bugs. Revisit when a number, not a feeling, justifies it. |
Whichever way you decide, the App Router model is worth understanding properly before you reject it, if only because it comes up constantly in Next.js interview questions and in architecture reviews. A migration decision made from a clear understanding of both models will survive review. One made from frustration usually will not.
Frequently Asked Questions
Is TanStack Start a replacement for Next.js?
No. It is an alternative for a specific class of applications. TanStack Start excels at client-heavy dashboards, real-time apps, and internal tools where Server Components add complexity without value. Next.js remains the stronger choice for SSR-heavy, SEO-critical marketing sites, content platforms, and teams deeply invested in the Vercel ecosystem.
Think of it as choosing the right tool for the job, not a universal upgrade.
Does TanStack Start support SSR?
Yes. TanStack Start supports SSR, SSG, and client-side rendering. The key difference is philosophy: it does not push you toward server-first patterns by default. You opt into SSR per route when it makes sense, rather than having to opt out of it.
Route loaders can run on the server during SSR or on the client during navigation. You control this at the route level.
How do I migrate Next.js redirects to TanStack Start?
Use Nitro's routeRules in your Vite config. The conversion is mostly mechanical: each source/destination pair becomes a key/value in routeRules.
// Nitro routeRules redirect format
routeRules: {
"/old-blog/:slug": { redirect: { to: "/articles/:slug", statusCode: 301 } },
"/legacy-page": { redirect: { to: "/new-page", statusCode: 308 } },
}What is Nitro in TanStack Start?
Nitro is the server framework layer that powers TanStack Start's backend. It handles redirects, HTTP headers, caching rules, API routes, and deployment adapters. It is the same project that powers Nuxt's server layer, so it is well-tested in production.
- Deployment presets: one config change switches between Node.js, Cloudflare Workers, Vercel, Deno, and more
- Route rules: declarative redirects, headers, and caching without middleware code
- Server routes: file-based API routes with automatic request parsing
- Auto-imports: utilities like
defineEventHandlerare available without explicit imports
Can I migrate gradually from Next.js to TanStack Start?
Yes, using the two-PR strategy described in this guide. Decouple from Next.js imports first (PR 1), then swap the framework (PR 2). Each step is independently deployable.
For larger apps, you can also run both frameworks in parallel during the transition. Set up path-based routing at the reverse proxy level (nginx, Cloudflare Workers, or your load balancer) to send some paths to the old Next.js app and others to the new TanStack Start app. Migrate routes in batches until the Next.js instance serves zero traffic, then decommission it.
What replaces Next.js server actions in TanStack Start?
Server functions, created with createServerFn. They serve the same purpose: a function that only ever executes on the server, callable from client code, with types preserved across the network boundary.
The important difference is what happens after the call. Next.js server actions integrate with the framework cache, so you invalidate by path or tag. TanStack Start has no framework-managed cache, so you invalidate the router or your query client explicitly. That is more lines of code and far fewer surprises about when stale data disappears.
Form integration also differs. Next.js can wire an action directly to a form element and give you pending state for free. In TanStack Start you call the server function from a submit handler or a mutation and manage pending state yourself, usually through TanStack Query or a form library.
Does TanStack Start have an equivalent of ISR?
Not as a drop-in feature. There is no on-demand revalidation API that regenerates a single page after a content change the way Next.js tag-based revalidation does.
What you have instead is HTTP caching expressed through route rules, plus whatever your CDN or hosting platform supports natively. Stale-while-revalidate headers on a route give you a similar user-facing result: fast cached responses with background freshening. What you lose is the ability to invalidate one specific page the moment its data changes, without waiting for a TTL.
If your content updates are infrequent and predictable, a rebuild-and-deploy on publish is often simpler than any revalidation scheme. If they are frequent and unpredictable, and precise invalidation genuinely matters to you, that is a strong argument for staying on Next.js.
Do I still need TanStack Query if routes have loaders?
Not always, but usually. Route loaders are enough when a page loads data once, renders it, and does not need to refresh it while the user is on the page. Documentation pages, settings screens, and read-only reports fit that description.
You want TanStack Query as soon as you have mutations that need to update other views, polling, websocket-driven updates, infinite lists, optimistic UI, or the same data rendered by several routes. The loader then becomes a prefetch step that primes the query cache, and the components subscribe to the cache as normal.
How long does a Next.js to TanStack Start migration take?
The published reports describe two pull requests, which sounds fast, but those teams had already removed most framework coupling and had strong end-to-end test coverage. The framework swap is rarely the expensive part.
Budget by how much Next.js-specific surface area you have rather than by route count. Apps with no Server Components, no image component usage beyond simple cases, and no parallel routes convert quickly. Apps with heavy server rendering, per-route edge runtimes, and framework-coupled auth take substantially longer, and the tail of small differences lasts longer than the swap itself.
A useful sanity check: convert one representative authenticated route end to end, including deploy, before you estimate anything. However long that takes, the rest of the app will not be proportionally faster.
Related Articles
Hono.js Tutorial: REST API with Zod, JWT & Cloudflare Workers
Step-by-step Hono.js tutorial: routing, middleware, Zod validation, JWT auth, and deployment to Cloudflare Workers and Node.js. Working code throughout.
GitHub Actions Tutorial: CI/CD from Push to Deploy (2026)
Learn GitHub Actions: write your first workflow, run tests automatically, use secrets safely, deploy via SSH, cache dependencies, and run matrix builds.
Caching Strategies Explained: CDN, Redis & DB Cache
A practical guide to caching strategies: browser cache, CDN, in-process memory, and Redis. Learn which layer to use, cache-aside patterns, and invalidation.