lifecycle/migrate-from-nextjs
lifecycle>-
Migrate from Next.js App Router to TanStack Start
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.
Pre-Migration
- Create a migration branch
git checkout -b migrate-to-tanstack-start
- Install TanStack Start
npm i @tanstack/react-start @tanstack/react-router
npm i -D vite @vitejs/plugin-react
- Remove Next.js
npm uninstall next @next/font @next/image
Concept Mapping
| 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 |
Step 1: Vite Configuration
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"
}
}
Step 2: Router Factory
// 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
}
Step 3: Convert Layout → Root Route
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>
)
}
Step 4: Convert Pages → File Routes
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:
- Dynamic segments use $param not [param]
- Params accessed via Route.useParams() not component props
- Route path in filename uses . or / separators
Step 5: Convert Server Actions → Server Functions
Next.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 }
})
Step 6: Convert Data Fetching
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} />
}
Step 7: Convert API Routes → Server Routes
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)
},
},
},
})
Step 8: Convert Navigation
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.
Step 9: Convert Middleware
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],
}))
Step 10: Convert Metadata/SEO
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 },
],
}),
})
Post-Migration Checklist
- Remove all "use server" and "use client" directives
- Remove next.config.js / next.config.ts
- Remove app/ directory (replaced by src/routes/)
- Remove middleware.ts (replaced by src/start.ts)
- Verify no next/* imports remain
- Run npm run dev and check all routes
- Verify server-only code is inside createServerFn (not bare in components/loaders)
- Check that <Scripts /> is in the root route <body>
Common Mistakes
1. CRITICAL: Keeping Server Component mental model
// 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,
})
2. CRITICAL: Using "use server" directive
// 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 () => { ... })
3. HIGH: Interpolating params into Link href
// 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>
Cross-References
- react-start — full React Start setup
- start-core/server-functions — server function patterns
- start-core/execution-model — isomorphic execution