Last week I scrapped a Next.js project mid-sprint. Not because it was broken, but because I was fighting the framework more than building with it. Route groups, layout nesting quirks, server action caching behavior I couldn't predict. I'd had enough. A colleague dropped a link in our team Slack: "Have you tried TanStack Start?" Two days later, I'd rewritten the whole thing and shipped it.
If you've been frustrated by the state of React meta-frameworks, TanStack Start might be exactly what you've been looking for. Let's walk through what it is, why it matters, and how to build something real with it.
What Is TanStack Start?
TanStack Start is a full-stack React framework from Tanner Linsley, the creator of TanStack Query (formerly React Query), TanStack Router, TanStack Table, and a half-dozen other libraries you probably already use. It's built on top of two foundations: TanStack Router for type-safe routing and Vite for blazing-fast builds.
The pitch is straightforward. You write standard React components. The framework handles SSR, streaming, server functions, and bundling. But unlike other frameworks that bolt server features onto React, Start was designed from the ground up with a client-first philosophy. You write client code, and the server capabilities wrap around it transparently.
Here's the architecture at a high level:
+--------------------------------------------------+
| Your React App |
+--------------------------------------------------+
| TanStack Start (Full-Stack Layer) |
| +--------------------------------------------+ |
| | Server Functions | SSR | Middleware | |
| +--------------------------------------------+ |
| TanStack Router (Type-Safe Routing) |
| +--------------------------------------------+ |
| | File Routes | Loaders | Search Params | |
| +--------------------------------------------+ |
| Vite (Build Tool) |
+--------------------------------------------------+
| Deploy Anywhere |
| Vercel / Netlify / Cloudflare / Node / Bun |
+--------------------------------------------------+The key difference from Next.js or Remix is the type safety story. Every route parameter, every search param, every server function input and output is typed end-to-end. No more guessing what shape your data has when it crosses the network boundary.
Setting Up Your First Project
Getting started takes about thirty seconds. You need Node 18+ and a package manager.
npx create-start-app@latest my-app
cd my-app
npm install
npm run devThe scaffolded project gives you a clean file structure:
my-app/
app/
routes/
__root.tsx # Root layout
index.tsx # Home page (/)
about.tsx # About page (/about)
router.tsx # Router configuration
client.tsx # Client entry
ssr.tsx # SSR entry
app.config.ts # TanStack Start config
package.json
tsconfig.json The app.config.ts file is minimal. TanStack Start uses Vite under the hood, so any Vite plugin works out of the box:
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
// Vite config goes here
vite: {
// Any Vite plugin works
},
})File-Based Routing That Actually Makes Sense
TanStack Router uses file-based routing, but with a twist: every route is fully typed. Create a file, and the router knows about it at the TypeScript level.
Here's a basic route:
// app/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
return (
<div>
<h1>Welcome to TanStack Start</h1>
<p>This page was server-rendered.</p>
</div>
)
} Dynamic routes work with the $ prefix. Create app/routes/posts.$postId.tsx and you get a typed postId parameter:
// app/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// params.postId is typed as string
const post = await fetchPost(params.postId)
return { post }
},
component: PostPage,
})
function PostPage() {
const { post } = Route.useLoaderData()
// post is fully typed based on your loader return
return <article><h1>{post.title}</h1></article>
} The magic happens when you navigate. The Link component knows which routes exist and what parameters they need:
import { Link } from '@tanstack/react-router'
// TypeScript error if '/posts/$postId' doesn't exist
// or if you forget the params
<Link to="/posts/$postId" params={{ postId: '42' }}>
Read Post
</Link>Try passing a route that doesn't exist. TypeScript catches it before you even save the file. Try forgetting a required param. Same thing. This is a different level of confidence compared to string-based routing.
Server Functions: RPC Without the Ceremony
Server functions are where TanStack Start really pulls ahead. You define a function, it runs on the server, and the client calls it with full type safety. No API routes to manage. No fetch wrappers to write. No type duplication.
import { createServerFn } from '@tanstack/react-start'
export const getServerTime = createServerFn().handler(async () => {
// This code ONLY runs on the server
return new Date().toISOString()
}) When you call getServerTime() from a component during SSR, it runs directly on the server. When you call it from the browser, the runtime swaps the function body for an HTTP request to an auto-generated endpoint. Same code, different execution contexts, zero configuration.
Here's a more practical example with input validation using Zod:
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(10),
tags: z.array(z.string()).max(5),
})
export const createPost = createServerFn({ method: 'POST' })
.inputValidator(CreatePostSchema)
.handler(async ({ data }) => {
// data is typed as { title: string; body: string; tags: string[] }
const post = await db.posts.create({ data })
return post
})The request flow looks like this:
Browser TanStack Start Server Handler
| | |
| createPost({ data }) | |
| --------------------------> | |
| | Validate with Zod |
| | --------+ |
| | | |
| | <-------+ |
| | |
| | POST /api/_server-fn/... |
| | -------------------------> |
| | | db.posts.create()
| | | ------+
| | | |
| | { id, title, ... } | <-----+
| | <------------------------- |
| Typed response | |
| <-------------------------- | |You can also access request headers, set response headers, throw redirects, and return 404s from server functions:
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
import { getRequestHeader } from '@tanstack/react-start/server'
export const requireAuth = createServerFn().handler(async () => {
const token = getRequestHeader('Authorization')
if (!token) {
throw redirect({ to: '/login' })
}
const user = await verifyToken(token)
return user
})Putting It All Together: A Task Board
Let's build something that ties these concepts together. A simple task board with server-side data loading, mutations, and real-time UI updates.
First, the server functions:
// app/server/tasks.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const getTasks = createServerFn().handler(async () => {
return db.tasks.findMany({ orderBy: { createdAt: 'desc' } })
})
export const addTask = createServerFn({ method: 'POST' })
.inputValidator(z.object({ title: z.string().min(1) }))
.handler(async ({ data }) => {
return db.tasks.create({
data: { title: data.title, completed: false },
})
})
export const toggleTask = createServerFn({ method: 'POST' })
.inputValidator(z.object({ id: z.string() }))
.handler(async ({ data }) => {
const task = await db.tasks.findUnique({ where: { id: data.id } })
return db.tasks.update({
where: { id: data.id },
data: { completed: !task.completed },
})
})Then the route with a loader:
// app/routes/tasks.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getTasks, addTask, toggleTask } from '../server/tasks'
import { useState } from 'react'
export const Route = createFileRoute('/tasks')({
loader: () => getTasks(),
component: TaskBoard,
})
function TaskBoard() {
const tasks = Route.useLoaderData()
const [newTitle, setNewTitle] = useState('')
const handleAdd = async () => {
if (!newTitle.trim()) return
await addTask({ data: { title: newTitle } })
setNewTitle('')
// Revalidate the route data
Route.router.invalidate()
}
return (
<div>
<h1>Task Board</h1>
<div>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="New task..."
/>
<button onClick={handleAdd}>Add</button>
</div>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => {
toggleTask({ data: { id: task.id } })
Route.router.invalidate()
}}
/>
{task.title}
</label>
</li>
))}
</ul>
</div>
)
}The route loader runs on the server during SSR, fetches the tasks, and the page arrives fully rendered. On the client, mutations go through server functions and then invalidate the loader to refresh the data. No API layer to wire up separately. No loading state to manage for the initial render.
Common Mistakes to Avoid
After building a few projects with TanStack Start, here are the pitfalls I've run into.
Don't skip input validation on server functions. It's tempting to just pass raw data through, but server functions are network endpoints under the hood. Treat them like any other API. Use Zod or another validator.
Don't fight the file-based routing convention. If you want a route at /dashboard/settings/notifications, create the file app/routes/dashboard.settings.notifications.tsx. The dot notation maps to URL segments. Resisting this convention makes the codebase harder to navigate.
Watch out for accidentally importing server-only code in client components. If your server function file imports a database client at the top level, make sure you're only importing the server function itself in your route files. TanStack Start tree-shakes server code, but sloppy imports can cause bundling issues.
Don't forget about search params. TanStack Router has first-class typed search params. Instead of reaching for React state or URL query strings manually, define your search params in the route:
export const Route = createFileRoute('/products')({
validateSearch: z.object({
page: z.number().default(1),
sort: z.enum(['price', 'name', 'date']).default('date'),
}),
}) Now Route.useSearch() returns a typed object and the URL stays in sync automatically.
Wrapping Up
TanStack Start fills a gap that's existed in the React ecosystem for a while. It gives you full-stack capabilities without the complexity overhead that comes with other meta-frameworks. The type safety isn't a nice-to-have, it's woven into every layer, from routes to server functions to search parameters.
If you're already using TanStack Router or TanStack Query, Start is the natural next step. If you're evaluating full-stack React frameworks for a new project, give it a serious look. The developer experience is noticeably better once you feel the confidence of end-to-end types catching your mistakes at compile time instead of in production.
The framework is currently at Release Candidate status, meaning the API is stable and it's production-ready for most use cases. The community is growing fast and the docs are solid.
Want to share your own experience with TanStack Start or another framework? Come write about it on CodeBrainery. We're a community of engineers and developers sharing practical knowledge. Your tutorial could help the next developer avoid the same headaches you went through.
If you found this useful, share it with your team or on social media. Happy building.
