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.
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
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>
);
}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" },
},
},
},
}),
],
});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.mjsFrequently 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.
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.