@tanstack/react-start
Version:
Modern and scalable routing for React applications
436 lines (343 loc) • 11.2 kB
Markdown
---
name: lifecycle/migrate-from-nextjs
description: >-
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.
type: lifecycle
library: tanstack-start
library_version: '1.166.2'
requires:
- start-core
- react-start
sources:
- TanStack/router:docs/start/framework/react/guide/server-functions.md
- TanStack/router:docs/start/framework/react/guide/middleware.md
- TanStack/router:docs/start/framework/react/guide/execution-model.md
---
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.
- [ ] **Create a migration branch**
```bash
git checkout -b migrate-to-tanstack-start
```
- [ ] **Install TanStack Start**
```bash
npm i @tanstack/react-start @tanstack/react-router
npm i -D vite @vitejs/plugin-react
```
- [ ] **Remove Next.js**
```bash
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:
```ts
// 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`:
```json
{
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node .output/server/index.mjs"
}
}
```
```tsx
// 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:
```tsx
// app/layout.tsx
export const metadata = { title: 'My App' }
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
```
TanStack Start:
```tsx
// 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:
```tsx
// app/posts/[id]/page.tsx
export default function PostPage({ params }: { params: { id: string } }) {
// ...
}
```
TanStack Start:
```tsx
// 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
Next.js:
```tsx
// 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:
```tsx
// 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:
```tsx
// 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:
```tsx
// 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:
```ts
// app/api/users/route.ts
export async function GET() {
const users = await db.users.findMany()
return Response.json(users)
}
```
TanStack Start:
```ts
// 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:
```tsx
import Link from 'next/link'
;<Link href={`/posts/${post.id}`}>View Post</Link>
```
TanStack Start:
```tsx
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:
```ts
// 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:
```tsx
// 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:
```tsx
export const metadata = {
title: 'Post Title',
description: 'Post description',
}
```
TanStack Start:
```tsx
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 },
],
}),
})
```
- [ ] 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
```tsx
// 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
```tsx
// 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
```tsx
// 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](../../react-start/SKILL.md) — full React Start setup
- [start-core/server-functions](../../../../start-client-core/skills/start-core/server-functions/SKILL.md) — server function patterns
- [start-core/execution-model](../../../../start-client-core/skills/start-core/execution-model/SKILL.md) — isomorphic execution