Deprecated. This skill lives under
deprecated/and is excluded frombunx skills add/--list. For ongoing TanStack Start work usetanstack-start-conventionsandreact-dev.
LLM reference: Fetch llms.txt for the TanStack documentation index. Human docs: Migrate from Next.js.
Next.js → TanStack Start Migration
Focus: mental model shift, data handling, and routing. TanStack Start = TanStack Router + Vite (+ Nitro for FMC deployment). No Next.js async server components in route files. Optional experimental RSC: tanstack-start-conventions → server-components.md.
After the mechanical migration, apply FMC stack skills: tanstack-start-conventions, tanstack-start-auth.
Critical mental model (read first)
TanStack Start is isomorphic by default. Components and loaders run on both server and client unless you isolate server-only logic in
createServerFn. This is the opposite of Next.js Server Components (server-only by default). Experimental RSC exists but is opt-in — see server-components.md.
| Next.js habit | TanStack Start reality |
|---|---|
Async server component + await db... |
Wrong — component code runs on client after navigation |
'use server' / 'use client' |
Remove both — use createServerFn for server-only |
getServerSideProps always on server |
Route loader runs on server or client (soft nav) |
| Direct DB/fs in loader | Wrap in createServerFn; loader calls the fn |
Loader on client nav: During client-side navigation, the loader runs in the browser. Any DB, filesystem, or secret access inside the loader must go through a server function (RPC on the client, direct call on the server).
When to Apply
- Migrating Next.js (App Router or Pages Router) to TanStack Start
- Converting Server Actions → Server Functions
- Replacing
getServerSideProps/ RSC fetch with loaders - Replacing
pages/api/*or App Routerroute.tshandlers
Quick Mapping
| Next.js | TanStack Start |
|---|---|
app/page.tsx / pages/index.tsx |
src/routes/index.tsx |
app/layout.tsx |
src/routes/__root.tsx |
app/posts/[slug]/page.tsx |
src/routes/posts/$slug.tsx |
app/posts/[...slug]/page.tsx |
src/routes/posts/$.tsx |
app/api/foo/route.ts |
src/routes/api/foo.ts (server.handlers) |
getServerSideProps |
Route loader (+ server fn for server-only I/O) |
Server Actions ('use server') |
createServerFn in *.functions.ts |
next/link href |
<Link to="..." params={...} /> |
next/navigation |
@tanstack/react-router hooks |
metadata export |
Route head property |
middleware.ts |
For auth/redirects: layout beforeLoad via route server fns (FMC). Do not migrate route auth to middleware. |
next.config.* |
vite.config.ts |
process.env |
import.meta.env (client: VITE_*) |
See references/nextjs-to-start-mapping.md for edge cases.
1. Project Setup (Vite + Nitro)
Greenfield scaffold (official):
bunx create @tanstack/start@latest my-app
Migrating in place — remove Next.js, install Start stack:
bun remove next @next/font @next/image
bun add @tanstack/react-router @tanstack/react-start nitro vite @vitejs/plugin-react
bun add -d @tailwindcss/vite tailwindcss # if using Tailwind v4 + Vite
package.json scripts:
{
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "bun .output/server/index.mjs"
}
}
bunfig.toml + Vite globalStore: tech-stack → bun-install.md
vite.config.ts (minimal):
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
export default defineConfig({
server: { port: 3000 },
resolve: { tsconfigPaths: true },
plugins: [
tailwindcss(),
tanstackStart(), // before viteReact()
viteReact(),
nitro(),
],
})
With FMC bunfig: add server.fs.allow for the Bun global cache.
src/router.tsx:
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
return createRouter({ routeTree, scrollRestoration: true })
}
Package names: Use @tanstack/react-start (not deprecated @tanstack/start). Do not use Vinxi — Start is a Vite plugin since v1.121.
FMC layout: Use default routesDirectory: 'routes' under src/ — not Next.js-style app/. See tanstack-start-conventions.
2. Root layout & pages
src/app/layout.tsx → src/routes/__root.tsx
import { Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router'
import appCss from '../styles/globals.css?url'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
component: RootLayout,
})
function RootLayout() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
)
}
page.tsx → index.tsx (or $param.tsx for dynamic segments):
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
return <main>...</main>
}
Params: Route.useParams() — not async params props. Search: validateSearch + Route.useSearch().
Links: Never interpolate params into to. Use typed params prop:
<Link to="/posts/$slug" params={{ slug: post.slug }}>
View
</Link>
3. Data: Loaders + Server Functions
Server-only I/O in loaders
// server/posts.functions.ts
import { createServerFn } from '@tanstack/react-start'
export const getAllPostsFn = createServerFn({ method: 'GET' }).handler(async () => {
return db.post.findMany() // or fs, secrets, etc.
})
// routes/index.tsx — thin route (FMC: component import from @/components/...)
export const Route = createFileRoute('/')({
loader: () => getAllPostsFn(),
component: HomePage,
})
FMC: React Query–backed data (preferred for shared/refetchable data)
Do not use useLoaderData alone for Query-backed data. Loader primes cache; component uses useSuspenseQuery:
// server/posts/queries/posts.server.ts — *QueryOptions
export const postsQueryOptions = () =>
queryOptions({ queryKey: ['posts'], queryFn: () => getAllPostsFn() })
export const Route = createFileRoute('/')({
loader: ({ context }) => context.queryClient.ensureQueryData(postsQueryOptions()),
component: HomePage,
})
// components/pages/PageHome.tsx
function HomePage() {
const { data: posts } = useSuspenseQuery(postsQueryOptions())
return ...
}
One-off admin data with no invalidation: loader return + useLoaderData is fine.
See tanstack-start-conventions → router-and-query.md and loader-data-patterns.md.
4. Server Actions → Server Functions
// server/profile.functions.ts — FMC: *.functions.ts, export *Fn
import { createServerFn } from '@tanstack/react-start'
export const updateProfileFn = createServerFn({ method: 'POST' })
.validator((data: { name: string }) => data)
.handler(async ({ data }) => {
await db.user.update({ ... })
return { ok: true }
})
Call from client: await updateProfileFn({ data: { name } }) in onSubmit — no formAction. Invalidate router or Query after mutations.
Rules (FMC): DB/session code in *.server.ts with createServerOnlyFn; client-callable RPC in *.functions.ts. Route files never import *.server.ts directly.
See references/server-functions.md.
5. API Routes
Public REST/webhooks — file route with server.handlers:
// routes/api/upload.image.ts
export const Route = createFileRoute('/api/upload/image')({
ssr: false,
server: {
handlers: {
POST: async ({ request }) => {
return Response.json({ ok: true })
},
},
},
})
App-internal calls: Prefer server functions over REST. API routes: no validateSearch; parse with Zod from request.url in GET; ssr: false on handler-only routes. See tanstack-start-conventions.
6. Auth
- FMC route auth: Auth redirects live in layout
beforeLoadvia route server functions (not direct session/DB in route files). Skill:tanstack-start-auth. - Do not use middleware for route auth: FMC page auth UX belongs in layout
beforeLoad. Middleware may exist for unrelated cross-cutting concerns, but it is not the auth-route replacement. - Data/API boundary: Private reads/writes use
endpointAuth.*/ guards inside server handlers or*.server.ts; Trassenscout enforces this with a custom ESLint pattern documented intanstack-start-auth.
Provider SDKs (Clerk, WorkOS) work at React level; server integration may need Start-specific adapters.
7. Other migrations
| Next.js | TanStack Start |
|---|---|
next/image |
Vite assets, @unpic/react, or <img> |
next/font |
Fontsource + Tailwind @theme in CSS |
next/head / metadata |
Route head (can use loaderData) |
| RSC async page | Loader + sync component + useLoaderData or Query |
generateStaticParams |
prerender in Vite/Start config |
Remove all "use server" and "use client" directives.
8. Migration Checklist
- Vite +
@tanstack/react-startconfigured; Vinxi/@tanstack/startremoved -
src/routes/__root.tsxwith<HeadContent />,<Scripts />,<Outlet /> - Pages → file routes;
[param]→$param -
src/router.tsx+ generatedrouteTree.gen.ts - All server-only I/O wrapped in
createServerFn/createServerOnlyFn - Server Actions →
*.functions.tswithvalidator - API routes →
routes/api/*.tswithserver.handlers(or server fns) - Query-backed UI:
ensureQueryDatain loader +useSuspenseQueryin component - Thin route files; UI in
components/(tanstack-start-conventions) - Explicit
ssron routes (tanstack-start-conventions) -
process.env→import.meta.env; nonext/*imports remain - Auth via
beforeLoad+ server fns (tanstack-start-auth) -
bun run dev— all routes and mutations work on hard refresh and client nav - E2E smoke:
playwright-skill—tests/smoke/,VITE_PLAYWRIGHT_ENABLED, stubbed auth (see tilda-geoapp/tests/)
References
| Topic | File |
|---|---|
| Concept mapping, file layout | nextjs-to-start-mapping.md |
| Loaders, loaderDeps, Query | loader-data-patterns.md |
| createServerFn, validation | server-functions.md |
FMC stack (post-migration): tanstack-start-conventions, tanstack-start-auth, playwright-skill (E2E smoke and patterns). URL state: router validateSearch first; skill nuqs only for shared libs or legacy patterns.
External: Official migration guide, execution model.