SuperAPI AI Directory

tanstack-start-migration

Frontend & websites

View repository

Updated 10-15 21:42

About this skill

Deprecated. This skill lives under deprecated/ and is excluded from bunx skills add / --list. For ongoing TanStack Start work use tanstack-start-conventions and react-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 Router route.ts handlers

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 beforeLoad via 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 in tanstack-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-start configured; Vinxi/@tanstack/start removed
  • src/routes/__root.tsx with <HeadContent />, <Scripts />, <Outlet />
  • Pages → file routes; [param] → $param
  • src/router.tsx + generated routeTree.gen.ts
  • All server-only I/O wrapped in createServerFn / createServerOnlyFn
  • Server Actions → *.functions.ts with validator
  • API routes → routes/api/*.ts with server.handlers (or server fns)
  • Query-backed UI: ensureQueryData in loader + useSuspenseQuery in component
  • Thin route files; UI in components/ (tanstack-start-conventions)
  • Explicit ssr on routes (tanstack-start-conventions)
  • process.env → import.meta.env; no next/* 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-geo app/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.