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 genuinely exciting. Before it, React setup still meant too much wiring: webpack, Babel, code splitting, or Create React App and its 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. CRA was officially deprecated in February 2025. Few people mourned it.
Next.js has been my default for full-stack React work since the App Router shipped. The reasons are mundane and practical: the ecosystem is mature, deploying to Vercel is frictionless, and Server Components let you strip JavaScript from pages that do not need it — genuinely useful for content-heavy routes where you used to reach for getStaticProps and hope for the best.
But somewhere along the way, parts of my Next.js apps started filling up with TanStack tools. TanStack Query for server state. TanStack Form for anything with real validation logic. TanStack Table for anything that needed sorting or filtering. By the time TanStack Start hit RC, curiosity was the only reason I needed — I was already living in the ecosystem, and I wanted to understand 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 with typed params, search params, loaders, and prefetching. Start adds the server layer on top: SSR, streaming, server functions, server routes, and deployment adapters.
The implication is significant. The router is not infrastructure in TanStack Start. It is the application. Start is what happens when you give TanStack Router a server.
This is why the framework describes itself as "Router-first." Not because routing is the only concern, but because 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.
TanStack has an official CLI — create-tsrouter-app — that scaffolds a project and lets you compose your stack from a set of first-party add-ons at init time.
npx @tanstack/cli create my-app --add-ons clerk,drizzle,tanstack-queryAvailable add-ons include authentication (Clerk, Better Auth), database (Drizzle ORM), UI (shadcn/ui, Tailwind), and TanStack Query. You pick what you need. Nothing is bundled by default.
Compare this to create-next-app, which scaffolds a Next.js project with no opinions about auth, database, or UI. Both are correct choices — Next.js expects you to reach for third-party solutions separately. TanStack's CLI makes those choices composable at the start.
A common stack in the TanStack ecosystem is Better Auth + Drizzle + PostgreSQL (Neon or Supabase) + shadcn/ui + TanStack Query. Not because the framework mandates it — because these tools share the same philosophy of explicit, composable primitives.
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)
To be precise: 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.
// 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 Router generates a fully typed route tree at build time. Every path param, every search param, every loader return type flows through TypeScript without a single manual annotation.
// 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.
src/routes/
__root.tsx → root layout + providers
index.tsx → /
blog/
index.tsx → /blog
$slug.tsx → /blog/:slug
dashboard/
_layout.tsx → layout for /dashboard/*
index.tsx → /dashboard
settings.tsx → /dashboard/settings
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. You reach for useSearchParams() and get ReadonlyURLSearchParams — a key-value string map with no schema. Most teams end up installing nuqs to get type safety and serialization.
// 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' // castingTanStack Router has typed search params built into the route definition. You define a schema once via validateSearch and every read is typed — no casting, no extra library.
import { 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 immediately 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 HTTP cache headers rather than a framework-managed ISR primitive.
TanStack Start has dedicated ISR docs — the approach uses CDN-level Cache-Control headers rather than a framework-managed abstraction. You set headers on server routes or use the built-in ISR guide. It is more explicit than revalidate: 60, but it is 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 { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
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>
}
const getUserProfile = createServerFn().handler(async () => {
const Renderable = await renderServerComponent(
<UserProfile userId="123" />
)
return { Renderable }
})
export const Route = createFileRoute('/profile')({
loader: async () => {
const { Renderable } = await getUserProfile()
return { UserProfile: Renderable }
},
component: ProfilePage,
})
function ProfilePage() {
const { UserProfile } = Route.useLoaderData()
return <>{UserProfile}</>
}The key difference: TanStack does not want RSC wrapped in black-box conventions with special rules. Heavy components that need server data stay out of the client bundle — but they behave like data, not like a separate rendering model layered on top of your app.
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.
// 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 scopeThis is a meaningful improvement — the implicit four-layer model was a real source of confusion. That said, Next.js 16's 'use cache' and TanStack's approach are now philosophically closer than they 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 TanStack Query to get caching — the router handles it. Query becomes useful when you need background refetching, window-focus revalidation, or shared cache across multiple components.
// 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'] })
}TanStack Query is optional — it adds background refetching and cross-component cache sharing on top of the router's built-in loader cache. Whether you need it depends on how dynamic your data is. For most routes, the router cache alone is enough.
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.
// 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>
)
}The colocation is ergonomic. The problem is scale: auth checks are repeated across every action, there is no standard way to compose middleware, and the 'use server' directive is a compiler hint that can behave unexpectedly.
createServerFn// 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 middleware chain is the real advantage. In Next.js, you still end up splitting concerns between edge-layer redirects and manual checks inside actions or route handlers. TanStack's middleware composes at the function level — auth, logging, rate limiting, input validation all in one chain. That chain is testable in isolation.
This one is quick. Next.js developers pick it up in a few minutes.
// Next.js — app/dashboard/layout.tsx
// Next.js finds this file automatically, wraps children
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
)
}// 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 that Next.js attaches to the route segment automatically.
// app/dashboard/error.tsx — Next.js
'use client'
export default function Error({ error, reset }) {
return <button onClick={reset}>Something went wrong: {error.message}</button>
}TanStack uses errorComponent on the route definition:
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:
// 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 has no direct equivalent to this pattern. The idiomatic approach is createServerFn called from a regular submit handler, combined with TanStack Form for state management:
// 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:
// app/api/posts/route.ts — Next.js
export async function GET(request: Request) {
const posts = await db.post.findMany()
return Response.json(posts)
}TanStack Start uses createServerFileRoute for the same purpose:
// 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:
// 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 uses @unhead/react (or react-helmet-async) for document head management:
// 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 serious enough to ship if it fits your app, but it is still a young framework and you feel that in a few places.
RSC is still experimental. TanStack Start has RSC support, but it is not the mature, default path that it is in Next.js. If your architecture depends heavily on that model today, the gap matters.
Static prerendering needs real testing. The docs are there and the feature works, but I would verify your exact content model before betting a docs site or marketing site on it.
The ecosystem is simply newer. When you hit an edge case, you are more likely to be reading source code or GitHub issues than finding a polished answer someone else has already written.
You bring more of the web platform yourself. Image optimization is the clearest example. Next.js gives you more batteries here; TanStack Start expects you to choose the pieces you want.
Before you write your first route, internalize these:
□ Loaders are isomorphic — run on server (SSR) AND browser (navigation)
Never access DB, secrets, or Node APIs directly in a loader
□ Secrets → createServerFn
If it would break if bundled to the client, wrap it in createServerFn
□ Server-only and client-only code are explicit
`createServerOnlyFn`, `createClientOnlyFn`, and import protection exist to make environment mistakes obvious
□ search params need a schema
Params and search params are route contracts, not loose strings you parse by hand
□ Auth is two layers, not one
`beforeLoad` guards the UX, server function middleware guards the data
In practice, you usually want both
□ Router cache first, TanStack Query optional
The router gives you a loader cache; Query adds background refetching and shared client cache
□ Middleware chains on server functions
Auth, logging, validation — compose once, use everywhere
□ RSC exists but works differently
TanStack RSC = Flight stream cached like data, not a server-first rendering model
Still experimental — don't depend on Next.js RSC conventions mapping 1:1
□ Test static prerendering against your real content model
Especially if you are building a docs site or a content-heavy marketing site
If you just want the quickest 1:1 translation layer in your head:
layout.tsx → pathless route + <Outlet />
app/layout.tsx → __root.tsx
error.tsx → errorComponent
"use server" action → createServerFn(...)
route.ts → createServerFileRoute(...)
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 starts to make 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. You write type helpers or reach for nuqs. TanStack Router generates a complete type-safe contract from your route tree — path params, search params, loader return types, all inferred without a single manual annotation. 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 when you want 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 on EC2, TanStack Start's adapter model will probably feel more neutral.
The ecosystem gap is real but narrow. Next.js has years of App Router-specific libraries, patterns, and Stack Overflow answers. If a third-party library ships a "use server" integration or a Next.js-specific hook, you are adapting it or skipping it. This matters less than it sounds for most apps — the core primitives like auth, database, email, and payments all have framework-agnostic APIs. But if your app has deep Next.js-specific dependencies, audit them before committing to a move.
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.