Next.js has dominated the full-stack React space for years. TanStack Start is the first serious alternative built from scratch for the current era — type-safe routing, server functions with real TypeScript inference, and a deployment model that runs anywhere Nitro runs.
TanStack Start is built on three pieces: TanStack Router for client-side routing, Vinxi as the application bundler, and Nitro as the server engine.
Project Setup
npx create-tsrouter-app@latest my-app --template start-basic
cd my-app
npm install
npm run dev
Project structure:
app/
├── routes/
│ ├── __root.tsx # Root layout
│ ├── index.tsx # /
│ └── posts/
│ ├── index.tsx # /posts
│ └── $postId.tsx # /posts/:postId — typed param
├── client.tsx
├── router.tsx
└── ssr.tsx
app.config.ts # Vinxi config — adapters, plugins
File-Based Routing — Typed Params
// app/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { fetchPost } from '../serverFunctions/posts'
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params }) => fetchPost(params.postId), // params.postId is typed
component: PostPage,
})
function PostPage() {
const post = Route.useLoaderData() // typed from loader return
const { postId } = Route.useParams() // typed, no casting
return <article><h1>{post.title}</h1><p>{post.content}</p></article>
}
The key difference from Next.js: Route.useLoaderData() is typed from the loader's return type automatically.
Server Functions
Server functions run on the server but are callable from the client — with full TypeScript inference on both sides.
// app/serverFunctions/posts.ts
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
export const fetchPost = createServerFn({ method: 'GET' })
.validator(z.object({ postId: z.string() }))
.handler(async ({ data }) => {
const post = await db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.id, data.postId),
with: { author: true }
})
if (!post) throw new Error('Post not found')
return post
})
export const createPost = createServerFn({ method: 'POST' })
.middleware([requireAuth])
.validator(z.object({ title: z.string().min(1), content: z.string().min(1) }))
.handler(async ({ data, context }) => {
return db.insert(posts).values({ ...data, authorId: context.user.id }).returning()
})
Using in a component:
function NewPostPage() {
const navigate = useNavigate()
const create = useServerFn(createPost)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const data = new FormData(e.currentTarget)
const post = await create({
data: { title: data.get('title') as string, content: data.get('content') as string }
})
navigate({ to: '/posts/$postId', params: { postId: post.id } })
}
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Publish</button>
</form>
)
}
Typed Search Params
const searchSchema = z.object({
page: z.number().default(1),
tag: z.string().optional(),
sort: z.enum(['newest', 'popular']).default('newest'),
})
export const Route = createFileRoute('/posts/')({
validateSearch: searchSchema,
loader: async ({ search }) => {
// search.page, search.tag, search.sort — all typed
return fetchPosts({ data: { ...search } })
},
component: PostsList,
})
Contrast with Next.js useSearchParams() which returns URLSearchParams — strings only, no validation.
Streaming SSR
export const Route = createFileRoute('/dashboard')({
loader: async () => {
const [stats, activityPromise] = await Promise.all([
fetchDashboardStats({ data: {} }),
defer(fetchRecentActivity({ data: {} })) // streams later
])
return { stats, activityPromise }
},
component: Dashboard,
})
function Dashboard() {
const { stats, activityPromise } = Route.useLoaderData()
return (
<div>
<StatsPanel stats={stats} /> {/* Immediate */}
<Suspense fallback={<ActivitySkeleton />}>
<Await promise={activityPromise}>
{(activity) => <ActivityFeed activity={activity} />}
</Await>
</Suspense>
</div>
)
}
Deployment
// app.config.ts
export default defineConfig({
server: {
preset: 'vercel', // or 'cloudflare-pages', 'aws-lambda', 'bun', 'node-server'
}
})
No vendor lock-in — switching deployment targets is a config change.
TanStack Start vs Next.js
| TanStack Start | Next.js App Router | |
|---|---|---|
| Route param types | End-to-end typed |
Promise<{}> — partial |
| Search param types | Zod schema, automatic | Manual casting |
| Server data fetching | Server functions | Server Components + Actions |
| Vendor lock-in | None (Nitro) | Vercel-optimized |
| Deploy targets | Anywhere Nitro runs | Vercel-optimized |
| Ecosystem maturity | Growing (2026 stable) | Mature |
When TanStack Start wins:
- Type safety is the top priority
- Deploying to Cloudflare Workers or non-Vercel infrastructure
- Already using TanStack Router and want the full-stack story
- Want server-side data fetching without the React Server Components mental model
When Next.js is still right:
- Heavy use of next/image, next/font, ISR, Vercel-specific features
- Large existing codebase — not worth migrating
- Team knows Next.js deeply
For new projects where type safety is a first-class requirement and you don't want to be tied to Vercel's infrastructure, TanStack Start is the serious alternative Next.js finally has.
Full article at stacknotice.com/blog/tanstack-start-complete-guide-2026