Step-by-step migration from Next.js App Router to TanStack Start: route definition conversion, API mapping, server function conversion from Server Actions, middleware conversion, data fetching pattern changes.
This is a step-by-step migration checklist. Complete tasks in order.
CRITICAL: TanStack Start is isomorphic by default. ALL code runs in both environments unless you use
createServerFn. This is the opposite of Next.js Server Components, where code is server-only by default.
CRITICAL: TanStack Start uses
createServerFn, NOT"use server"directives. Do not carry over any"use server"or"use client"directives.
CRITICAL: Types are FULLY INFERRED in TanStack Router/Start. Never cast, never annotate inferred values.
git checkout -b migrate-to-tanstack-start
npm i @tanstack/react-start @tanstack/react-router
npm i -D vite @vitejs/plugin-react
npm uninstall next @next/font @next/image
| Next.js App Router | TanStack Start |
|---|---|
app/page.tsx | src/routes/index.tsx |
app/layout.tsx | src/routes/__root.tsx |
app/posts/[id]/page.tsx | src/routes/posts/$postId.tsx |
app/api/users/route.ts | src/routes/api/users.ts (server property) |
"use server" + Server Actions | createServerFn() |
"use client" | Not needed (everything is isomorphic) |
| Server Components (default) | All components are isomorphic; use createServerFn for server-only logic |
next/navigation useRouter | useRouter() from @tanstack/react-router |
next/link Link | <Link> from @tanstack/react-router |
next/head or metadata export | head property on route |
middleware.ts (edge) | createMiddleware() in src/start.ts |
next.config.js | vite.config.ts with tanstackStart() |
generateStaticParams | prerender config in vite.config.ts |
Replace next.config.js with:
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
tanstackStart(), // MUST come before react()
viteReact(),
],
})
Update package.json:
{
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node .output/server/index.mjs"
}
}
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
const router = createRouter({
routeTree,
scrollRestoration: true,
})
return router
}
Next.js:
// app/layout.tsx
export const metadata = { title: 'My App' }
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
TanStack Start:
// src/routes/__root.tsx
import type { ReactNode } from 'react'
import {
Outlet,
createRootRoute,
HeadContent,
Scripts,
} from '@tanstack/react-router'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
],
}),
component: RootComponent,
})
function RootComponent() {
return (
<html>
<head>
<HeadContent />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
)
}
Next.js:
// app/posts/[id]/page.tsx
export default function PostPage({ params }: { params: { id: string } }) {
// ...
}
TanStack Start:
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
// ...
}
Key differences:
$param not [param]Route.useParams() not component props. or / separatorsNext.js:
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.posts.create({ title })
}
TanStack Start:
// src/utils/posts.functions.ts
import { createServerFn } from '@tanstack/react-start'
export const createPost = createServerFn({ method: 'POST' })
.inputValidator((data) => {
if (!(data instanceof FormData)) throw new Error('Expected FormData')
return { title: data.get('title')?.toString() || '' }
})
.handler(async ({ data }) => {
await db.posts.create({ title: data.title })
return { success: true }
})
Next.js Server Component:
// app/posts/page.tsx (Server Component — server-only by default)
export default async function PostsPage() {
const posts = await db.posts.findMany()
return <PostList posts={posts} />
}
TanStack Start:
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
const getPosts = createServerFn({ method: 'GET' }).handler(async () => {
return db.posts.findMany()
})
export const Route = createFileRoute('/posts')({
loader: () => getPosts(), // loader is isomorphic, getPosts runs on server
component: PostsPage,
})
function PostsPage() {
const posts = Route.useLoaderData()
return <PostList posts={posts} />
}
Next.js:
// app/api/users/route.ts
export async function GET() {
const users = await db.users.findMany()
return Response.json(users)
}
TanStack Start:
// src/routes/api/users.ts
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/users')({
server: {
handlers: {
GET: async () => {
const users = await db.users.findMany()
return Response.json(users)
},
},
},
})
Next.js:
import Link from 'next/link'
;<Link href={`/posts/${post.id}`}>View Post</Link>
TanStack Start:
import { Link } from '@tanstack/react-router'
;<Link to="/posts/$postId" params={{ postId: post.id }}>
View Post
</Link>
Never interpolate params into the to string. Use params prop.
Next.js:
// middleware.ts
export function middleware(request: NextRequest) {
const token = request.cookies.get('session')
if (!token) return NextResponse.redirect(new URL('/login', request.url))
}
export const config = { matcher: ['/dashboard/:path*'] }
TanStack Start:
// src/start.ts — must be manually created
import { createStart, createMiddleware } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
const authMiddleware = createMiddleware().server(async ({ next, request }) => {
const cookie = request.headers.get('cookie')
if (!cookie?.includes('session=')) {
throw redirect({ to: '/login' })
}
return next()
})
export const startInstance = createStart(() => ({
requestMiddleware: [authMiddleware],
}))
Next.js:
export const metadata = {
title: 'Post Title',
description: 'Post description',
}
TanStack Start:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => fetchPost(params.postId),
head: ({ loaderData }) => ({
meta: [
{ title: loaderData.title },
{ name: 'description', content: loaderData.excerpt },
{ property: 'og:title', content: loaderData.title },
],
}),
})
"use server" and "use client" directivesnext.config.js / next.config.tsapp/ directory (replaced by src/routes/)middleware.ts (replaced by src/start.ts)next/* imports remainnpm run dev and check all routescreateServerFn (not bare in components/loaders)<Scripts /> is in the root route <body>// WRONG — treating component as server-only (Next.js habit)
function PostsPage() {
const posts = await db.posts.findMany() // fails on client
return <div>{posts.map(...)}</div>
}
// CORRECT — use server function + loader
const getPosts = createServerFn({ method: 'GET' }).handler(async () => {
return db.posts.findMany()
})
export const Route = createFileRoute('/posts')({
loader: () => getPosts(),
component: PostsPage,
})
// WRONG — "use server" is Next.js/React pattern
'use server'
export async function myAction() { ... }
// CORRECT — use createServerFn
export const myAction = createServerFn({ method: 'POST' })
.handler(async () => { ... })
// WRONG — Next.js pattern
<Link to={`/posts/${post.id}`}>View</Link>
// CORRECT — TanStack Router pattern
<Link to="/posts/$postId" params={{ postId: post.id }}>View</Link>