TanStack Start: A Mental Model for Next.js Developers
Understand TanStack Start from a Next.js developer’s perspective: its router-first architecture, explicit server boundaries, typed routing, caching, rendering, and server functions.
Understand TanStack Start from a Next.js developer’s perspective: its router-first architecture, explicit server boundaries, typed routing, caching, rendering, and server functions.
My first encounter with Next.js was exciting. Before it, React setup meant too much wiring: webpack, Babel, code splitting, or Create React App's sealed black box. When Next.js arrived with file-system routing, API routes, and SSR that actually worked, it felt like the framework React had always needed.
Next.js has been my default for full-stack React work since the App Router shipped. The ecosystem is mature, deploying to Vercel is frictionless, and Server Components let you strip JavaScript from pages that don't need it — genuinely useful for content-heavy routes.
But somewhere along the way, my Next.js apps started filling up with TanStack tools. Query for server state. Form for validation. Table for sorting and filtering. By the time TanStack Start hit RC, I was already living in the ecosystem. I wanted to see what the full picture looked like without Next.js in the middle.
So I tried it. This is what I found.
TanStack Start is the philosophical inverse of Next.js. Nothing is implicit. Every boundary is declared. The server is something you reach for, not something you live in by default.
That reversal is the entire mental model shift. Everything else follows from it.
A centralized server-first tower on one side, a modular router-first graph on the other.
Most full-stack frameworks pick a rendering model first and build routing around it. Next.js did. Pages Router, then App Router — the router exists to serve the rendering pipeline.
TanStack Start came from the other direction. TanStack Router existed first as a standalone, fully type-safe router. Start adds the server layer on top: SSR, streaming, server functions, deployment adapters.
The router is not infrastructure in TanStack Start. It is the application. Everything else — data fetching, server functions, caching, code splitting — is organized around the router's type system, not the other way around.
Next.js's App Router is a file system that generates routes. TanStack Router is a type system that generates a route tree. Both are file-based. The difference is what the output is used for.
The official CLI — create-tsrouter-app — scaffolds a project and lets you compose your stack from first-party add-ons at init time:
npx @tanstack/cli create my-app --add-ons clerk,drizzle,tanstack-queryAdd-ons cover auth (Clerk, Better Auth), database (Drizzle), UI (shadcn/ui, Tailwind), TanStack Query and more — you pick what you need, nothing is bundled by default. Where create-next-app leaves auth/db/UI for you to wire up separately, TanStack's CLI makes those choices composable up front.
👉 Next.js: components are Server Components by default → opt into client ("use client")
👉 TanStack Start: components are isomorphic React by default → opt into server (createServerFn)
TanStack Start still SSRs the initial request. The difference is that components are regular React — they run on both server and client, not server-only.
The server is a layer you reach for explicitly via createServerFn, not the default execution context for every component.
In Next.js, the question is "does this component need to run on the client?" In TanStack Start, the question is "does this logic need to run only on the server?"
The second question is harder to answer incorrectly.
Next.js routing is a file system convention. The folder structure app/blog/[slug]/page.tsx creates a route /blog/:slug. This is intuitive, but the type system has no idea what slug is. TanStack Router generates a fully typed route tree at build time instead — every path param, search param, and loader return type flows through TypeScript with no manual annotation:
// Next.js — params typed as Promise<Record<string, string>>
// You're casting, trusting convention, or reaching for a type helper
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
// TypeScript trusts you that `slug` exists. It has no way to verify.
}// TanStack Start — params are typed from route definition
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
// params.slug is string — TypeScript knows because the route says so
return fetchPost(params.slug)
},
component: function BlogPost() {
const { slug } = Route.useParams() // typed
const post = Route.useLoaderData() // typed to loader return value
return <article>{post.title}</article>
},
})Misspell slg instead of slug? Compile error. Pass the wrong search param shape during navigation? TypeScript catches it. This is not a convenience feature — it eliminates an entire class of runtime bugs that Next.js apps ship regularly.
TanStack Router generates a routeTree.gen.ts file that is the source of truth for your app's type system. You do not edit this file. The router reads your src/routes/ directory and writes it automatically on every dev server reload.
The underscore prefix (_layout.tsx) is TanStack's way of marking pathless layout routes — routes that wrap children without adding a URL segment.
In Next.js, search params are untyped strings — useSearchParams() returns ReadonlyURLSearchParams, a key-value string map with no schema, so most teams install nuqs for type safety and serialization. TanStack Router builds typed search params into the route definition: you define a schema once via validateSearch and every read is typed — no casting, no extra library.
// Next.js — no types, no validation, manual parsing
const searchParams = useSearchParams()
const page = Number(searchParams.get('page') ?? '1')
const sort = searchParams.get('sort') as 'asc' | 'desc' // castingimport { z } from 'zod'
export const Route = createFileRoute('/posts')({
validateSearch: z.object({
page: z.number().catch(1),
sort: z.enum(['asc', 'desc']).catch('desc'),
q: z.string().optional(),
}),
component: function Posts() {
const { page, sort, q } = Route.useSearch() // fully typed
return <PostList page={page} sort={sort} query={q} />
},
})
// Navigation — TypeScript enforces valid search params
<Link to="/posts" search={{ page: 2, sort: 'asc' }}>Next</Link>
// TS error if you pass page: 'two' or sort: 'random'This is one of the most productive parts of TanStack Router for Next.js developers. Filters, pagination, tabs driven by URL — all typed end to end.
This is where most Next.js developers get burned in their first week.
In Next.js, a Server Component is server-only by definition. You fetch inside it and the data never touches the client bundle.
// Next.js — this runs ONLY on the server, always
export default async function Dashboard() {
const user = await db.user.findUnique({ where: { id: session.userId } })
return <DashboardView user={user} />
}TanStack Start route loaders look similar but behave differently. Loaders are isomorphic. They run on the server during SSR and in the browser after hydration for client-side navigations.
// ⚠️ This looks safe but is not
export const Route = createFileRoute('/dashboard')({
loader: async () => {
// During SSR: runs on server, env var exists, works
// During client navigation: runs in browser, env var is undefined
// Worse: if bundled, the variable value ships to the client
const conn = new DatabaseClient(process.env.DATABASE_URL)
return conn.query('SELECT * FROM users')
},
})The fix is explicit: wrap server-only logic in createServerFn.
const getDashboardData = createServerFn().handler(async () => {
// This ONLY runs on the server, regardless of when it's called
const conn = new DatabaseClient(process.env.DATABASE_URL)
return conn.query('SELECT * FROM users')
})
export const Route = createFileRoute('/dashboard')({
loader: () => getDashboardData(),
component: function Dashboard() {
const data = Route.useLoaderData()
return <DashboardView data={data} />
},
})The loader calls the server function. The server function has the boundary. This is explicit, this is auditable, and it does not silently change behavior based on navigation type.
TanStack Start goes further than just createServerFn. The framework also gives you createServerOnlyFn, createClientOnlyFn, and import-protection rules so environment mistakes fail loudly instead of turning into accidental leaks. That fits the same philosophy: execution boundaries are something you declare, not something you infer from a file ending up on the server.
| Loader | Server Function | |
|---|---|---|
| Runs isomorphically | ✓ | ✗ (server only) |
| Good for | Fetching data for SSR + client nav | DB queries, auth checks, secrets |
| Can be cached by TanStack Query | ✓ | ✓ (via loader) |
| Direct database access | ✗ | ✓ |
In Next.js, auth protection usually ends up split between edge-layer redirects and manual session checks inside each Server Action or Route Handler. The two levels are separate concerns with separate APIs.
TanStack Start makes the two levels explicit and composable.
Level 1 — beforeLoad: runs before the route loads, on both server and client. Redirect unauthenticated users before any data fetching happens.
// src/routes/dashboard/_layout.tsx
export const Route = createFileRoute('/dashboard/_layout')({
beforeLoad: async ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href }, // preserve intended destination
})
}
},
component: DashboardLayout,
})Level 2 — server function middleware: protects the data itself. Even if someone bypasses the UI redirect, the server function rejects the request.
const withAuth = createMiddleware({ type: 'function' }).server(async ({ next, context }) => {
const session = await getServerSession()
if (!session) throw redirect({ to: '/login' })
return next({ context: { user: session.user } })
})
const getUserData = createServerFn()
.middleware([withAuth])
.handler(async ({ context }) => {
// context.user is typed and guaranteed — no null check needed
return db.user.findUnique({ where: { id: context.user.id } })
})The beforeLoad guard is a UX concern. The server function middleware is a security concern. In Next.js, teams often blur those concerns between proxy.ts (aka middleware.ts) and ad hoc checks inside actions. TanStack's two-level model makes the split harder to ignore.
If you have internalized Next.js's rendering vocabulary (SSR, SSG, ISR, PPR, RSC), here is how it maps.
Both frameworks render on the server by default. TanStack Start's beforeLoad and loader run on the server during the initial request, then the component renders to HTML.
export const Route = createFileRoute('/posts')({
loader: async () => {
// Runs on server for initial load, browser for navigations
return fetchPosts()
},
})TanStack Start has static prerendering support and dedicated docs for it. I would still treat it as something to test against your exact setup instead of assuming it will cover every content-site edge case the way Next.js does. For a SaaS app with a handful of marketing pages, that may not matter. For a large docs site, it probably does.
Next.js ISR is revalidate: 60 and you mostly stop thinking about it. TanStack Start takes a lower-level route: you control stale-while-revalidate behavior through CDN-level Cache-Control headers rather than a framework-managed ISR primitive. There are dedicated ISR docs — you set headers on server routes or follow the built-in guide. More explicit than revalidate: 60, but not undocumented manual work either.
// TanStack Start server route with cache headers
import { createServerFileRoute } from '@tanstack/react-start/server'
export const ServerRoute = createServerFileRoute('/api/posts').methods({
GET: async () => {
const posts = await fetchPosts()
return new Response(JSON.stringify(posts), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
},
})
},
})Whether "explicit Cache-Control" vs "framework-managed ISR" is better depends on your deployment target. On Cloudflare or a CDN you control, explicit headers are often the right call.
TanStack Start now has experimental React Server Components support, and the implementation is deliberately different from Next.js's.
In Next.js, RSC is a rendering primitive. The server owns the component tree and sends HTML. The client hydrates. Caching is framework-managed.
In TanStack Start, RSC is treated more like a React Flight payload the client can fetch, cache, and compose into the UI tree. That makes it feel closer to data loading than to Next.js's server-owned component tree.
It also requires explicit setup. RSC is not on by default in TanStack Start; you enable it in the Start plugin and wire in the build-tool-specific RSC support.
import { renderServerComponent } from '@tanstack/react-start/rsc'
async function UserProfile({ userId }: { userId: string }) {
const user = await db.user.findUnique({ where: { id: userId } })
return <div>{user.name}</div>
}
// Render the component to a Flight payload inside a server function...
const getUserProfile = createServerFn().handler(async () => ({
Renderable: await renderServerComponent(<UserProfile userId="123" />),
}))
// ...then load it like any other data and drop it into the tree.
export const Route = createFileRoute('/profile')({
loader: async () => ({ UserProfile: (await getUserProfile()).Renderable }),
component: () => <>{Route.useLoaderData().UserProfile}</>,
})The payoff: heavy components that need server data stay out of the client bundle, without wrapping them in black-box conventions with special rules.
This is experimental as of writing. If your architecture depends on RSC in production, Next.js's implementation is more mature. But the "TanStack has no RSC" characterization is no longer accurate.
This is something Next.js does not expose with a similarly direct route option. TanStack Start lets you opt individual routes out of SSR entirely.
export const Route = createFileRoute('/admin/analytics')({
ssr: false, // renders client-side only, no server HTML
loader: () => fetchAnalytics(),
})Useful for authenticated dashboards where server-rendering adds latency with no SEO benefit.
Next.js caching has gone through significant revisions. The four-layer model (Request Memoization, Data Cache, Full Route Cache, Router Cache) that shipped with the App Router is now the "Previous Model" in the docs. Next.js 16 replaced it with the 'use cache' directive — an opt-in approach where nothing is cached by default, and you explicitly mark components, functions, or entire files as cached.
This is a meaningful improvement — the implicit four-layer model was a real source of confusion, and Next.js 16's 'use cache' is now philosophically closer to TanStack's approach than it used to be. TanStack Start has two caching layers: TanStack Router's built-in loader cache (per-route, configurable staleTime), and optionally TanStack Query for more granular control. You do not need Query to get caching — the router handles it. Query becomes useful when you need background refetching, window-focus revalidation, or shared cache across components.
// Next.js 16 — new opt-in 'use cache' model
// app.config: cacheComponents: true
async function getPosts() {
'use cache'
// explicitly cached — key auto-generated from inputs
return db.posts.findMany()
}
// vs old model: implicit caching via fetch() options, multiple layers,
// revalidateTag/revalidatePath with non-obvious scope// Caching lives in queryOptions, not in fetch()
const postsQueryOptions = queryOptions({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 60_000, // serve from cache for 60s
gcTime: 5 * 60_000, // keep in memory for 5min
})
// Prefetch in loader (runs on server for SSR)
export const Route = createFileRoute('/posts')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(postsQueryOptions),
component: function Posts() {
const { data } = useSuspenseQuery(postsQueryOptions)
return <PostList posts={data} />
},
})
// Invalidate after mutation — explicit, predictable
async function handleDelete(id: string) {
await deletePost({ data: { id } })
await queryClient.invalidateQueries({ queryKey: ['posts'] })
}The tradeoff either way: one cache with an explicit API, versus Next.js 16's opt-in 'use cache' which is simpler than before but still compiler-magic.
createServerFnBoth are RPC. The design decisions differ in ways that matter at scale. A Next.js Server Action is colocated and ergonomic; createServerFn trades that colocation for an explicit method, validator, and composable middleware chain.
// Collocated with the component, implicit POST, no validation built in
async function deletePost(id: string) {
'use server'
// Auth check is manual every time
const session = await getServerSession()
if (!session) throw new Error('Unauthorized')
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}
export function PostCard({ id }: { id: string }) {
return (
<form action={() => deletePost(id)}>
<button type="submit">Delete</button>
</form>
)
}// auth middleware — write once, compose everywhere
const withAuth = createMiddleware({ type: 'function' }).server(async ({ next }) => {
const session = await getServerSession()
if (!session) throw redirect({ to: '/login' })
return next({ context: { user: session.user } })
})
// Server function with explicit method, validator, middleware
const deletePost = createServerFn({ method: 'POST' })
.middleware([withAuth])
.validator(z.object({ id: z.string() }))
.handler(async ({ data, context }) => {
// context.user is typed and guaranteed by middleware
await db.post.delete({
where: { id: data.id, authorId: context.user.id },
})
})
// Usage — called like any async function
await deletePost({ data: { id: postId } })The Next.js colocation is ergonomic, but the problem is scale: auth checks repeat across every action, there is no standard way to compose middleware, and 'use server' is a compiler hint that can behave unexpectedly. The middleware chain is TanStack's real advantage — auth, logging, rate limiting, and validation compose at the function level, in one chain, testable in isolation. In Next.js you still split those concerns between edge-layer redirects and manual checks inside actions.
This one is quick. Where Next.js auto-discovers app/dashboard/layout.tsx and passes a children prop, TanStack declares the layout as a route and renders an explicit <Outlet />:
// TanStack Start — src/routes/dashboard/_layout.tsx
// Explicit Outlet instead of children prop
import { Outlet, createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard/_layout')({
component: function DashboardLayout() {
return (
<div className="dashboard">
<Sidebar />
<main>
<Outlet /> {/* child routes render here */}
</main>
</div>
)
},
})The _ prefix makes this a pathless layout route — it wraps /dashboard/* without adding /_layout to the URL. Same concept as Next.js route groups (groupName), different syntax.
Nested layouts work by nesting Outlet components. The mental model is the same.
__root.tsx: Where Providers LiveIn Next.js, app/layout.tsx is where you mount global providers — QueryClientProvider, theme, auth context, toast, etc. TanStack's equivalent is src/routes/__root.tsx.
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'
interface RouterContext {
queryClient: QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: function Root() {
return (
<>
<Outlet />
<TanStackRouterDevtools />
</>
)
},
})The createRootRouteWithContext pattern is worth paying attention to. The RouterContext type flows into every route's beforeLoad and loader via context — typed, no prop drilling. Note: this client/router context is separate from server function context. Server functions have their own context pipeline via middleware, and client-side context is not automatically forwarded to them.
Next.js has error.tsx — a file-based error boundary it attaches to the route segment automatically. TanStack uses errorComponent on the route definition:
// app/dashboard/error.tsx — Next.js
'use client'
export default function Error({ error, reset }) {
return <button onClick={reset}>Something went wrong: {error.message}</button>
}export const Route = createFileRoute('/dashboard')({
errorComponent: ({ error, reset }) => (
<button onClick={reset}>Something went wrong: {error.message}</button>
),
component: Dashboard,
})Same concept, co-located with the route instead of a separate file. You can also set a global error component on __root.tsx as a fallback.
The same explicitness shows up on the client side too. If something truly depends on the browser — localStorage, timezone, analytics scripts, DOM-only widgets — TanStack Start gives you ClientOnly and useHydrated instead of pretending SSR and hydration will sort it out for you automatically.
Next.js 16 has a native form action pattern built on React 19's useActionState. TanStack Start has no direct equivalent — the idiomatic approach is createServerFn called from a regular submit handler, combined with TanStack Form for state:
// Next.js — form action + useActionState for pending/error state
'use client'
import { useActionState } from 'react'
async function createPost(prevState: unknown, formData: FormData) {
'use server'
const title = formData.get('title') as string
if (!title) return { error: 'Title required' }
await db.post.create({ data: { title } })
}
export function CreatePostForm() {
const [state, action, isPending] = useActionState(createPost, null)
return (
<form action={action}>
<input name="title" />
{state?.error && <p>{state.error}</p>}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
</form>
)
}// TanStack Start — server function + TanStack Form
const createPost = createServerFn({ method: 'POST' })
.validator(z.object({ title: z.string().min(1) }))
.handler(async ({ data }) => {
await db.post.create({ data })
})
export function CreatePostForm() {
const form = useForm({
defaultValues: { title: '' },
onSubmit: async ({ value }) => {
await createPost({ data: value })
},
})
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
<form.Field name="title" children={(field) => (
<input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
)} />
<button disabled={form.state.isSubmitting}>
{form.state.isSubmitting ? 'Creating...' : 'Create'}
</button>
</form>
)
}More explicit, more composable — but also more setup. If you are used to the ergonomics of Next.js form actions for simple mutations, TanStack's approach feels heavier until you have TanStack Form set up as a standard.
Next.js has route.ts files for API endpoints; TanStack Start uses createServerFileRoute for the same purpose:
// app/api/posts/route.ts — Next.js
export async function GET(request: Request) {
const posts = await db.post.findMany()
return Response.json(posts)
}// src/routes/api/posts.ts — TanStack Start
import { createServerFileRoute } from '@tanstack/react-start/server'
export const ServerRoute = createServerFileRoute('/api/posts').methods({
GET: async ({ request }) => {
const posts = await db.post.findMany()
return Response.json(posts)
},
})Functionally identical. The distinction: TanStack server routes and server functions are separate concepts. Server routes are for public-facing HTTP endpoints (REST APIs, webhooks). Server functions are for internal client-server RPC. Do not use server functions as API endpoints for external consumers — they have CSRF protection and serialization constraints that assume a same-origin TanStack client.
Next.js has a built-in Metadata API — you export metadata or generateMetadata from any page or layout. TanStack Start uses @unhead/react (or react-helmet-async) for document head management:
// Next.js — built-in metadata API
export const metadata = {
title: 'My Post',
description: 'Post description',
openGraph: { images: ['/og.png'] },
}
// Or dynamic:
export async function generateMetadata({ params }) {
const post = await fetchPost(params.slug)
return { title: post.title }
}// TanStack Start — useHead from @unhead/react
import { useHead } from '@unhead/react'
export const Route = createFileRoute('/blog/$slug')({
loader: ({ params }) => fetchPost(params.slug),
component: function BlogPost() {
const post = Route.useLoaderData()
useHead({ title: post.title, meta: [{ name: 'description', content: post.summary }] })
return <article>{post.content}</article>
},
})Not as tightly integrated as Next.js — @unhead/react is a third-party library rather than a framework primitive. That said, TanStack Start does have dedicated SEO docs, so this is more "bring your own head management primitive" than "figure it out yourself from scratch."
Some App Router features do not map cleanly because TanStack Start is solving a different problem. Parallel routes and intercepting routes are good examples. If your app leans heavily on those conventions, you will feel the difference. If not, they are probably a distraction from the main mental-model shift.
TanStack Start is still in Release Candidate status. It is serious enough to evaluate and ship if it fits your app, but it is a young framework and you feel that in places.
RSC is still experimental. TanStack Start has RSC support, but it is not the mature, default path it is in Next.js. If your architecture depends heavily on that model, the gap matters.
Static prerendering needs real testing. The docs are there and the feature works, but verify your content model before betting a docs site on it.
The ecosystem is simply newer. When you hit an edge case, you are more likely reading source code or GitHub issues than finding a polished answer.
You bring more of the web platform yourself. Image optimization is the clearest example. Next.js gives you batteries; TanStack Start expects you to choose your own.
Not "which is better." What does your app actually need.
👉 Your data fetching pattern determines a lot. If your pages are mostly server-rendered with minimal client interactivity — content sites, marketing pages, documentation — Next.js's server-first model will feel more natural. TanStack's RSC story is still experimental, so this is not where I would start if that rendering style is central to the app.
👉 If your app is a SaaS dashboard, an admin panel, an authenticated product — heavy client interactivity, user-specific data, mutations everywhere — TanStack Start's model makes more sense. The explicit server boundary and simpler client-side mental model line up better with that kind of app.
👉 TypeScript discipline matters. In Next.js, useParams() returns Record<string, string | string[]> — you cast or trust the filename. useSearchParams() returns ReadonlyURLSearchParams with no schema. TanStack Router generates a complete type-safe contract from your route tree — path params, search params, loader return types, all inferred. If you have ever written const { id } = params as { id: string } and felt bad about it, TanStack's routing is a meaningful upgrade, not a preference.
👉 The caching story is genuinely different. Next.js's cache has historically had multiple layers with different invalidation rules, which is why so many App Router caching bugs feel harder than they should. TanStack Start is simpler: the router has a loader cache, and TanStack Query is optional for richer client-side cache behavior. Fewer moving parts, fewer places for stale data to hide.
👉 Deployment target is a real constraint. If you run on Vercel, Next.js has first-class support for edge functions, ISR, and incremental deploys with no configuration. If you are deploying to Cloudflare Workers, self-hosted Node, or a Docker container, TanStack Start's adapter model will probably feel more neutral.
The framework is not better. The mental model is different. Next.js optimizes for productivity on the happy path. TanStack Start optimizes for predictability when you want to see every boundary clearly.
Most of the things I like about TanStack Start are subtractions. Less magic. Less implicit behavior. Less framework-level caching to debug. That is a tradeoff, not a free win — you get predictability in exchange for more explicit code.
Whether that tradeoff is worth making depends on your team and your app. But if you have ever spent an afternoon debugging Next.js cache behavior and wished the framework just did less, TanStack Start is worth an honest look.
I'm planning to write regularly about React, and the TanStack ecosystem — alongside building AI agents and AI tools. More on adarsha.dev/blog, or say hi on X or LinkedIn.