TanStack Start Skills
Overview
TanStack Start is a full-stack React framework built on TanStack Router, powered by Vite and Nitro (via Vinxi). It provides server-side rendering, streaming, server functions (RPC), middleware, API routes, and deploys to any platform via Nitro presets.
Package: @tanstack/react-start
Router Plugin: @tanstack/router-plugin
Build Tool: Vinxi (Vite + Nitro) Status: RC (Release Candidate) RSC Support: React Server Components support is in active development and will land as a non-breaking v1.x addition
Installation & Project Setup
npx @tanstack/cli create my-app
Or manually:
npm install @tanstack/react-start @tanstack/react-router react react-dom npm install -D @tanstack/router-plugin typescript vite vite-tsconfig-paths
Project Structure
my-app/ app/ routes/ __root.tsx # Root layout index.tsx # / route posts.$postId.tsx # /posts/:postId api/ users.ts # /api/users API route client.tsx # Client entry router.tsx # Router creation ssr.tsx # SSR entry routeTree.gen.ts # Auto-generated route tree app.config.ts # TanStack Start config tsconfig.json package.json
Configuration (app.config.ts )
import { defineConfig } from '@tanstack/react-start/config' import viteTsConfigPaths from 'vite-tsconfig-paths'
export default defineConfig({ vite: { plugins: [ viteTsConfigPaths({ projects: ['./tsconfig.json'] }), ], }, server: { preset: 'node-server', // 'vercel' | 'netlify' | 'cloudflare-pages' | etc. }, tsr: { appDirectory: './app', routesDirectory: './app/routes', generatedRouteTree: './app/routeTree.gen.ts', }, })
Server Functions (createServerFn )
Server functions provide type-safe RPC calls between client and server.
Basic Server Functions
import { createServerFn } from '@tanstack/react-start'
// GET (data fetching, cacheable) const getUsers = createServerFn() .handler(async () => { const users = await db.query.users.findMany() return users })
// POST (mutations, side effects) const createUser = createServerFn({ method: 'POST' }) .validator((data: { name: string; email: string }) => data) .handler(async ({ data }) => { const user = await db.insert(users).values(data).returning() return user })
With Zod Validation
import { z } from 'zod'
const updateUser = createServerFn({ method: 'POST' }) .validator( z.object({ id: z.string(), name: z.string().min(1), email: z.string().email(), }) ) .handler(async ({ data }) => { // data is fully typed: { id: string; name: string; email: string } return await db.update(users).set(data).where(eq(users.id, data.id)) })
Middleware
Creating Middleware
import { createMiddleware } from '@tanstack/react-start'
const loggingMiddleware = createMiddleware().handler(async ({ next }) => { console.log('Request started') const result = await next() console.log('Request completed') return result })
Auth Middleware with Context
const authMiddleware = createMiddleware().handler(async ({ next }) => { const request = getWebRequest() const session = await getSession(request)
if (!session?.user) { throw redirect({ to: '/login' }) }
// Pass typed context to handler return next({ context: { user: session.user } }) })
Chaining Middleware
const adminMiddleware = createMiddleware() .middleware([authMiddleware]) .handler(async ({ next, context }) => { // context.user is typed from authMiddleware if (context.user.role !== 'admin') { throw redirect({ to: '/unauthorized' }) } return next({ context: { isAdmin: true } }) })
// Usage const adminAction = createServerFn({ method: 'POST' }) .middleware([adminMiddleware]) .handler(async ({ context }) => { // context: { user: User; isAdmin: boolean } return { success: true } })
API Routes (Server Routes)
// app/routes/api/users.ts import { createAPIFileRoute } from '@tanstack/react-start/api'
export const APIRoute = createAPIFileRoute('/api/users')({ GET: async ({ request }) => { const users = await db.query.users.findMany() return Response.json(users) }, POST: async ({ request }) => { const body = await request.json() const user = await db.insert(users).values(body).returning() return new Response(JSON.stringify(user), { status: 201 }) }, })
SSR Strategies
Streaming SSR (Default)
export const Route = createFileRoute('/dashboard')({ loader: async () => ({ criticalData: await fetchCriticalData(), deferredData: defer(fetchSlowData()), }), component: Dashboard, })
function Dashboard() { const { criticalData, deferredData } = Route.useLoaderData() return ( <div> <CriticalSection data={criticalData} /> <Suspense fallback={<Loading />}> <Await promise={deferredData}> {(data) => <SlowSection data={data} />} </Await> </Suspense> </div> ) }
Deployment
Supported Platforms (Nitro Presets)
// app.config.ts export default defineConfig({ server: { preset: 'node-server', // Self-hosted Node.js // preset: 'vercel', // Vercel // preset: 'netlify', // Netlify // preset: 'cloudflare-pages', // Cloudflare Pages // preset: 'aws-lambda', // AWS Lambda // preset: 'deno-server', // Deno Deploy // preset: 'bun', // Bun }, })
Best Practices
-
Use validators for all server function inputs - runtime safety and TypeScript inference
-
Compose middleware for cross-cutting concerns (auth, logging, rate limiting)
-
Use createServerFn GET for data fetching (cacheable, preloadable)
-
Use createServerFn POST for mutations and side effects
-
Use beforeLoad for route-level auth guards
-
Use defer() for non-critical data to improve TTFB
-
Set defaultPreload: 'intent' on the router for instant navigation
-
Co-locate server functions with the routes that use them
Common Pitfalls
-
Server functions cannot close over client-side variables (they're extracted to separate bundles)
-
Data returned from server functions must be serializable
-
Forgetting await in loaders leads to streaming issues
-
Importing server-only code in client bundles causes build errors
-
Missing declare module '@tanstack/react-router' loses all type safety