1. Introduction
React Vite starter is a great way to learn React, but it’s no longer recommended for production use. When building real-world applications, you’ll want a more robust framework that provides routing, state management, image optimization, SSR, and other features out of the box.
The React ecosystem currently has three main frameworks:
Let’s explore TanStack Start and why it might be perfect for your next project. Disclaimer: I’m not affiliated with TanStack - just a fan of their work!
2. What is TanStack Start? 🔧
From their documentation: “Full-stack React and Solid framework powered by TanStack Router SSR, Streaming, Server Functions, API Routes, bundling and more powered by TanStack Router and Vite.”
That’s right! If you’re in the React ecosystem, you probably know about TanStack Query, TanStack Table, TanStack Router, and recently TanStack Form. Most of these are built by the same team and by tannerlinsley himself.
TanStack Start aims to bring the “server” side of the equation with SSR, Streaming, Server Functions, API Routes, and many more features. You can finally build a full-stack application with a single framework. About 90% of TanStack Start is powered by TanStack Router, leaving just the server bits to the “start” implementation.
3. Why TanStack Start vs Next.js / React Router 7? ⚡
Tech choices are hard, and you need to choose the right tool for the job. Here are my reasons for picking TanStack Start over Next.js or React Router 7:
- React Router 7’s / Remix direction: While React Router 7 powers a big part of the ecosystem alongside Next.js, and promisses to keep it up to date are there, Remix ( previously known as React Router ) seems to be stepping back from “React” - read this post for more info. This makes me unconfortable with so many decisions / rebrands taking place in the past few years.
- Documentation clarity: React Router 7 lacks proper documentation and examples. It feels complex to understand and implement.
- RSC complexity: Next.js placed their bets on RSC and a new React paradigm. While RSC is great, it adds extra complexity to how we build React applications.
- Performance trade-offs: Next.js server-side rendering feels slower compared to traditional SPAs and needs more powerful machines to run (we’re pushing workload to the server instead of the client).
- Vite ecosystem: This is personal preference, but the Vite ecosystem is huge, fast, and has better DX compared to Webpack/Turbopack.
- Type safety: TanStack Router is simply the best type-safe router for React - query params, context, everything is fully typed.
4. Core Architecture & Philosophy
TanStack Start represents a paradigm shift in how we think about full-stack React applications. While frameworks like Next.js have embraced a “server-first” approach with React Server Components, TanStack Start takes a fundamentally different path: client-first architecture with powerful server capabilities.
4.1 Client-First vs Server-First Philosophy
The key differentiator lies in TanStack Start’s philosophy:
“While other frameworks continue to compromise on the client-side application experience we’ve cultivated as a front-end community over the years, TanStack Start stays true to the client-side first developer experience, while providing a full-featured server-side capable system that won’t make you compromise on user experience.”
This means:
- Initial navigation: Server-side rendering for fast initial page loads and SEO
- Subsequent navigation: Client-side routing for instant, SPA-like experiences
- Data loading: Isomorphic loaders that run on server during SSR, client during navigation
- Interactivity: Rich client-side interactions without server-side compromises
4.2 Isomorphic Loaders: The Game Changer 🎯
TanStack Start’s most innovative feature is its isomorphic loaders. Unlike traditional meta-frameworks where server loaders and client state exist in separate worlds, TanStack Start bridges this gap elegantly:
export const Route = createFileRoute('/posts')({
component: Page,
validateSearch: zodValidator(PostSearchParamsSchema),
loaderDeps: ({ search }) => ({ search }),
search: {
middlewares: [stripSearchParams(PostSearchParamsDefaultValues)],
},
loader: async ({ deps, context }) => context.queryClient.ensureQueryData(postsQueryOptions(deps.search)),
head: () => ({
meta: createMetaPage({
title: 'Posts',
description: 'Your personal post collection',
}),
}),
})
/**
* Page
*/
function Page() {
const results = Route.useLoaderData()
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{results.records.map((post) => <PostCard post={post} key={post.id} />)}
</div>
</div>
)
}
This solves the “impedance mismatch” problem where:
- Server loaders blindly re-fetch all data on navigation ( can pair with TanStack Query )
- Client state gets ignored by server-side logic
- You end up requesting data you already have cached
4.3 Server Functions: Not Your Typical RPCs
TanStack Start introduces createServerFn
- a unique approach to server-side logic:
export const likePost = createServerFn({ method: 'POST' })
.middleware([authedMiddleware])
.validator(LikePostSchema)
.handler(async ({ data }) => {
if (!data.postId) {
throw new Error('Post is not valid or doesnt exist anymore')
}
const headers = getHeaders()
await auth.api.likePost({
headers: headers as HeadersInit,
body: {
postId: data.postId,
},
})
return { message: 'Post liked successfully' }
})
Key benefits:
- Call from anywhere: Components, hooks, loaders - full flexibility
- Type-safe end-to-end: Input validation with inference
- No API layer needed: Direct function calls that proxy to server
- Automatic serialization: Complex data types handled seamlessly
4.4 TanStack Query Integration
Unlike other frameworks, TanStack Start seamlessly integrates with TanStack Query for sophisticated caching:
const postsQuery = useQuery({
queryKey: ['posts'],
queryFn: () => getPostsServerFn()
})
This enables:
- Smart caching: Only fetch what’s not already cached
- Background updates: Stale-while-revalidate patterns
- Optimistic updates: Immediate UI feedback
- Shared state: Between server and client seamlessly
For more info, check out the TanStack Query documentation.
4.5 Type Safety Throughout
TanStack Start builds on TanStack Router’s foundation of 100% inferred TypeScript:
- Routes: Fully typed path parameters and search params
- Navigation: Type-safe links with autocomplete
- Server functions: Input/output type inference
- Data flow: From server to client with full type preservation
4.6 The Architecture in Practice
Here’s how it all comes together:
-
Initial Load:
- Server renders page with data from loaders
- Client receives HTML + serialized data
- Hydration with full state restoration
-
Client Navigation:
- Router intercepts navigation
- Loaders run on client with cache awareness
- Only missing data gets fetched
- Instant page updates
-
Server Interactions:
- Server functions called like regular functions
- Automatic proxy to server via fetch
- Type safety maintained throughout
This architecture enables the best of both worlds: fast initial loads like traditional SSR with rich client-side experiences like SPAs, without the typical compromises or complexity.
5. Routing: File-Based vs Virtual Routes 🛣️
Just like Next.js, you can define file-based routes - check the full documentation here.
But the real power comes with virtual routes - this is where the DX starts to shine! Even better, you can mix and match file-based routes with virtual routes.
Here’s an example of a virtual route:
import { rootRoute, route, index } from '@tanstack/virtual-file-routes'
export const routes = rootRoute('root.tsx', [
index('landing/index.tsx'),
// Private pages
route('/dashboard', 'dashboard/layout.tsx', [
// Bookmarks
route('/posts', 'dashboard/posts/layout.tsx', [
index('dashboard/posts/index.tsx'),
route('/create', 'dashboard/posts/create.tsx'),
route('/$id', 'dashboard/posts/view.tsx'),
]),
// User Area
route('/user', [
// Teams
route('/teams', [
route('$slug', [route('/switch', 'dashboard/user/switch-team.tsx')]),
]),
route('/logout', 'dashboard/user/logout.tsx'),
]),
]),
// Public pages
route('/auth', 'auth/layout.tsx', [
route('/sign-in', 'auth/sign-in.tsx'),
]),
// Api
route('/api', [
// BetterAuth
route('/auth', [route('$', 'api/auth.ts')]),
]),
])
6. Middleware 🔧
You probably know what middlewares are. While they come “for free” in other frameworks like Laravel and Nuxt, they’re not always easy to implement in React.
Thankfully, TanStack Start has a very nice way to define middleware for server functions and routes. Let’s explore them.
6.1 Server Functions & Request Middleware 🔒
This ensures that the server function is only called if the middleware passes or doesn’t throw an exception. Here’s a demo example for better auth:
async function authMiddlewareHandler(request: Request | null | undefined): Promise<AuthMaybeSessionContext> {
const context: AuthMaybeSessionContext = {
isAuthenticated: false,
session: null,
user: null,
organization: null,
organizations: [],
}
if (!request) {
return context
}
const authSession = await auth.api.getSession({
headers: request.headers,
})
// Add authenticated data if session exists
if (authSession?.session && authSession.user) {
Object.assign(context, {
isAuthenticated: true,
session: authSession.session as unknown as Session,
user: authSession.user as unknown as User,
organization: organization as Organization,
organizations: organizations as Organization[],
})
}
return context
}
/**
* This middleware is used to authenticate the user.
* It sets the session in the context.
*/
export const authMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const request = getWebRequest()
const context = await authMiddlewareHandler(request)
return next({
context,
})
})
/**
* This middleware is used to authenticate the user.
* It sets the session in the context.
*/
export const authWebMiddleware = createMiddleware({ type: 'request' })
.server(async ({ next }) => {
const request = getWebRequest()
const context = await authMiddlewareHandler(request)
return next({
context,
})
})
6.2 Router “Middleware” 🚦
While TanStack Router doesn’t have the concept of middleware, users often use the beforeLoad
hook to achieve the same effect.
Here are examples of how to pass context from “root.tsx” into a gated route:
// root.tsx
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async ({ context }) => {
try {
const authContext = await context.queryClient.ensureQueryData(authQueryOptions())
return {
...authContext,
}
} catch (error) {
logger.error('Unable to load the auth context', error)
return {}
}
},
})
// dashboard/layout.tsx
export const Route = createFileRoute('/dashboard/_layout')({
component: Layout,
beforeLoad: async ({ context }) => {
const { isAuthenticated } = context
if (!isAuthenticated || !context.session || !context.user || !context.organization || !context.organizations) {
throw redirect({
to: '/auth/sign-in'
})
}
return AuthSessionContextSchema.parse(context)
}
})
// Server route middleware
export const ServerRoute = createServerFileRoute().methods((api) => ({
GET: api
.middleware([authMiddleware])
.handler(async ({ request }) => {
return new Response('Hello, World! from ' + request.url)
}),
}))
Wait, what’s happening here? Let’s break it down:
- We use
beforeLoad
on theroot.tsx
route to load the auth context (whether the user is logged in or not) - The QueryClient fetches the auth context from server side and persists it in the query cache
- The context is then passed to child routes, in this case the
dashboard/layout.tsx
route - The
dashboard/layout.tsx
route uses thebeforeLoad
hook to check if the user is authenticated - if not, we redirect to the login page - The
AuthSessionContextSchema
is a Zod schema that validates the context and ensures it’s valid & fully typed - You can repeat this process for any route you want (even nested ones)
7. Server & Client Boundaries 🔐
This is a crucial concept to understand and a major security concern for full-stack applications in the JavaScript ecosystem. Blurring the lines between server & client provides good DX, but it also opens up security concerns if you don’t know what you’re doing.
A missed server import into the client can lead to your server application logic or even environment variables being exposed to the client!
Next.js handles this elegantly with the use 'client'
directive at the top of files - anything not tagged with client
is treated as server code, ensuring no server code executes on the client.
React Router 7 & SvelteKit use file naming conventions to determine if a file is server or client: post.server.tsx
or post.client.tsx
While TanStack Start doesn’t YET have a built-in way to handle this, you can use the Vite Plugin vite-env-only to ensure that files and directories matching your patterns don’t get imported into the client bundle. Here’s an example:
import { defineConfig } from 'vite'
import { denyImports } from 'vite-env-only'
export default defineConfig({
plugins: [
// .. other plugins,
denyImports({
client: {
specifiers: [
"fs", /^node:/, "drizzle-orm", "postgres",
"node-ray", "Buffer", "process", "path", "os",
"url", "crypto", "stream", "net",
],
files: [
"**/.server/*",
"**/*.server.*",
"src/console/*",
"src/services/*",
"src/console/*",
"src/jobs/*"
],
},
}),
]
})
8. Deployment 🚀
TanStack being based on Vite, h3 & Nitro (at least for now), makes it a perfect fit for deployment on almost any platform!
All you have to do is toggle your target
in your vite.config.ts
file, and you’re good to go!
// vite.config.ts
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { defineConfig } from 'vite'
// Targets can be : cloudflare-module, vercel, netlify, bun, node, etc.
export default defineConfig({
plugins: [tanstackStart({ target: 'vercel' })],
// ... other vite config
})
Don’t forget to check the documentation - sometimes you need to tweak the config to make it work on your platform of choice.
9. Conclusion ✨
TanStack Start is a powerful meta-framework that’s changing the game in the React ecosystem. If you haven’t tried it yet, you should - grab some coffee and start building!
You can join their TanStack Discord to get help, ask questions, and get updates on the framework.