- Published on
TanStack Start: The Next.js Challenger That Changes Everything
- Authors
- Name
- Zairyl Zafra
- @zrylzfra
The Problem
Miguel sat in the coffee shop, laptop open, frustration building. He'd been trying to understand Next.js App Router for three days straight.
// Miguel's confusion with Next.js App Router
// app/products/[id]/page.tsx
// Wait... is this Server Component or Client Component?
export default async function ProductPage({ params }) {
// I can use async here? Cool!
const product = await fetch(`/api/products/${params.id}`)
// But I can't use useState? Why?
// const [count, setCount] = useState(0); // ERROR!
// So I need a separate Client Component?
return (
<div>
<ProductInfo product={product} />
<AddToCartButton /> {/* 'use client' in another file */}
</div>
)
}
// app/components/AddToCartButton.tsx
;('use client') // What does this even mean?!
export function AddToCartButton() {
const [loading, setLoading] = useState(false)
// Now I can use hooks, but I'm in a different file...
}
"Why is this so complicated?" Miguel muttered. "Server Components, Client Components, 'use client', 'use server'... I just want to build an app!"
His friend Elena, a senior developer, looked over. "Frustrated with Next.js?"
"I don't understand the mental model," Miguel admitted. "When am I on the server? When am I on the client? Why do I need to split everything into different files?"
Elena smiled. "Let me show you something new: TanStack Start."
The Discovery
Elena opened her laptop and showed Miguel a different approach:
// TanStack Start - No confusion
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/products/$id')({
// Server-side data loading (clear and explicit)
loader: async ({ params }) => {
const product = await db.products.findById(params.id)
return { product }
},
// Component (runs on client, but has server data)
component: ProductPage
})
function ProductPage() {
const { product } = Route.useLoaderData()
const [count, setCount] = useState(0) // Works! No 'use client' needed
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}</p>
<button onClick={() => setCount(count + 1)}>
Add to Cart ({count})
</button>
</div>
)
}
// Everything in one place. Clear separation. No magic.
Miguel's eyes widened. "Wait... that's it? No Server/Client Component split? No 'use client' directive?"
"Yep," Elena nodded. "TanStack Start has a simpler mental model. Let me explain the difference."
Understanding the Mental Models
Next.js App Router: The React Server Components Way
┌─────────────────────────────────────────┐
│ Server Components (default) │
│ - Async/await supported │
│ - No useState, useEffect │
│ - Direct database access │
│ - Zero client JavaScript │
│ │
│ 'use client' ←─────────────┐ │
│ │ │
│ ┌──────────────────────┐ │ │
│ │ Client Components │ │ │
│ │ - useState/useEffect │←──┘ │
│ │ - Event handlers │ │
│ │ - Browser APIs │ │
│ │ - Sends JS to client │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────┘
Mental Model: "Everything is server unless you say 'use client'"
TanStack Start: The Traditional SPA Way (Enhanced)
┌─────────────────────────────────────────┐
│ Server (loader functions) │
│ - Data fetching │
│ - Database queries │
│ - Authentication │
│ - Runs once per route │
└────────────┬────────────────────────────┘
│
│ Data flows down
↓
┌─────────────────────────────────────────┐
│ Client (React components) │
│ - All React features work │
│ - useState, useEffect, etc. │
│ - Event handlers │
│ - Receives data from loader │
└─────────────────────────────────────────┘
Mental Model: "Server loads data, client renders with full React"
Creating Your First TanStack Start App
Step 1: Installation
# Create new TanStack Start project
npm create @tanstack/start@latest my-app
# Choose your options:
# ✓ TypeScript? Yes
# ✓ TailwindCSS? Yes
# ✓ Deploy target? Vercel
cd my-app
npm install
npm run dev
Compare with Next.js:
# Next.js
npx create-next-app@latest my-app
# Similar, but different file structure and conventions
Step 2: Project Structure
TanStack Start:
my-app/
├── app/
│ ├── routes/
│ │ ├── index.tsx # Home page
│ │ ├── about.tsx # /about route
│ │ └── products/
│ │ ├── index.tsx # /products
│ │ └── $id.tsx # /products/:id
│ ├── router.tsx # Router config
│ └── ssr.tsx # SSR entry
├── public/
└── package.json
Next.js App Router:
my-app/
├── app/
│ ├── page.tsx # Home page
│ ├── about/
│ │ └── page.tsx # /about route
│ ├── products/
│ │ ├── page.tsx # /products
│ │ └── [id]/
│ │ └── page.tsx # /products/:id
│ ├── layout.tsx # Root layout
│ └── api/ # API routes
├── public/
└── package.json
Step 3: Your First Route
TanStack Start:
// app/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: HomePage
})
function HomePage() {
return (
<div className="p-8">
<h1 className="text-4xl font-bold">Welcome to TanStack Start!</h1>
<p className="mt-4">Simple, powerful, type-safe.</p>
</div>
)
}
Next.js:
// app/page.tsx
export default function HomePage() {
return (
<div className="p-8">
<h1 className="text-4xl font-bold">Welcome to Next.js!</h1>
<p className="mt-4">The React Framework.</p>
</div>
)
}
Difference: TanStack uses explicit route creation, Next.js uses file system routing.
Data Fetching: The Big Difference
Scenario: Fetch User Profile
TanStack Start:
// app/routes/profile.tsx
import { createFileRoute } from '@tanstack/react-router'
// Server-side loader (runs once on navigation)
export const Route = createFileRoute('/profile')({
loader: async () => {
// Direct database access (server-side)
const user = await db.users.findCurrent()
const posts = await db.posts.findByUser(user.id)
// Return data to component
return { user, posts }
},
component: ProfilePage
})
function ProfilePage() {
// Access loader data (type-safe!)
const { user, posts } = Route.useLoaderData()
// All React hooks work here!
const [editing, setEditing] = useState(false)
const handleEdit = () => {
setEditing(true)
}
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
{editing ? (
<EditForm user={user} />
) : (
<button onClick={handleEdit}>Edit Profile</button>
)}
<PostList posts={posts} />
</div>
)
}
// Everything in one file. Clear flow. No magic.
Next.js App Router:
// app/profile/page.tsx (Server Component)
export default async function ProfilePage() {
// This is a Server Component - can use async
const user = await db.users.findCurrent()
const posts = await db.posts.findByUser(user.id)
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
{/* Need Client Component for interactivity */}
<EditButton />
<PostList posts={posts} />
</div>
)
}
// app/components/EditButton.tsx (Client Component)
'use client' // Required for useState
import { useState } from 'react'
export function EditButton() {
const [editing, setEditing] = useState(false)
// But now I don't have access to user data!
// Need to pass it as props or fetch again
return (
<button onClick={() => setEditing(true)}>
Edit Profile
</button>
)
}
// Data and interactivity split across files
The Mental Load
TanStack Start:
✓ Loader = Server
✓ Component = Client (with server data)
✓ One file per route
✓ Clear separation
Next.js:
⚠️ Is this Server or Client Component?
⚠️ Can I use hooks here?
⚠️ Do I need 'use client'?
⚠️ Where should this code live?
Real-World Example: Blog Platform
Let's build a blog to see the differences clearly.
TanStack Start: Blog Implementation
// app/routes/blog/index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/blog/')({
// Server-side data loading
loader: async () => {
const posts = await db.posts.findAll({
include: { author: true },
orderBy: { createdAt: 'desc' }
})
return { posts }
},
component: BlogListPage
})
function BlogListPage() {
const { posts } = Route.useLoaderData()
const [search, setSearch] = useState('')
const filteredPosts = posts.filter(post =>
post.title.toLowerCase().includes(search.toLowerCase())
)
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
{/* Client-side search - works seamlessly */}
<input
type="search"
placeholder="Search posts..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full p-4 border rounded mb-8"
/>
{/* Render server data with client interactivity */}
<div className="space-y-6">
{filteredPosts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
)
}
function PostCard({ post }) {
const [liked, setLiked] = useState(false)
return (
<article className="border rounded-lg p-6">
<h2 className="text-2xl font-bold">{post.title}</h2>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-gray-500">
By {post.author.name}
</span>
<button
onClick={() => setLiked(!liked)}
className={liked ? 'text-red-500' : 'text-gray-400'}
>
{liked ? '❤️' : '🤍'} Like
</button>
</div>
</article>
)
}
// app/routes/blog/$slug.tsx
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
const post = await db.posts.findBySlug(params.slug, {
include: { author: true, comments: true }
})
if (!post) throw notFound()
return { post }
},
component: BlogPostPage
})
function BlogPostPage() {
const { post } = Route.useLoaderData()
const [comment, setComment] = useState('')
const [comments, setComments] = useState(post.comments)
const handleSubmit = async (e) => {
e.preventDefault()
const newComment = await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ postId: post.id, text: comment })
}).then(r => r.json())
setComments([...comments, newComment])
setComment('')
}
return (
<article className="max-w-3xl mx-auto p-8">
<h1 className="text-5xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-600 mb-8">
By {post.author.name} • {formatDate(post.createdAt)}
</div>
<div
className="prose lg:prose-xl"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<div className="mt-12 border-t pt-8">
<h2 className="text-2xl font-bold mb-4">Comments</h2>
<form onSubmit={handleSubmit} className="mb-8">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full p-4 border rounded"
placeholder="Add a comment..."
rows={4}
/>
<button
type="submit"
className="mt-2 px-6 py-2 bg-blue-500 text-white rounded"
>
Post Comment
</button>
</form>
<div className="space-y-4">
{comments.map(comment => (
<CommentCard key={comment.id} comment={comment} />
))}
</div>
</div>
</article>
)
}
// Everything works together seamlessly!
Next.js App Router: Blog Implementation
// app/blog/page.tsx (Server Component)
export default async function BlogListPage() {
// Server-side data loading
const posts = await db.posts.findAll({
include: { author: true },
orderBy: { createdAt: 'desc' }
})
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
{/* Need Client Component for search */}
<SearchBar />
{/* Can't filter client-side - posts are in Server Component! */}
<div className="space-y-6">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
)
}
// app/components/SearchBar.tsx (Client Component)
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export function SearchBar() {
const [search, setSearch] = useState('')
const router = useRouter()
// Can't filter posts here - they're in Server Component!
// Need to use searchParams instead
const handleSearch = () => {
router.push(`/blog?search=${search}`)
}
return (
<input
type="search"
placeholder="Search posts..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
/>
)
}
// app/components/PostCard.tsx (Client Component)
'use client'
export function PostCard({ post }) {
const [liked, setLiked] = useState(false)
return (
<article className="border rounded-lg p-6">
<h2 className="text-2xl font-bold">{post.title}</h2>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-gray-500">
By {post.author.name}
</span>
<button
onClick={() => setLiked(!liked)}
className={liked ? 'text-red-500' : 'text-gray-400'}
>
{liked ? '❤️' : '🤍'} Like
</button>
</div>
</article>
)
}
// app/blog/[slug]/page.tsx (Server Component)
export default async function BlogPostPage({ params }) {
const post = await db.posts.findBySlug(params.slug, {
include: { author: true, comments: true }
})
if (!post) notFound()
return (
<article className="max-w-3xl mx-auto p-8">
<h1 className="text-5xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-600 mb-8">
By {post.author.name} • {formatDate(post.createdAt)}
</div>
<div
className="prose lg:prose-xl"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
{/* Need Client Component for comments */}
<CommentSection postId={post.id} initialComments={post.comments} />
</article>
)
}
// app/components/CommentSection.tsx (Client Component)
'use client'
export function CommentSection({ postId, initialComments }) {
const [comment, setComment] = useState('')
const [comments, setComments] = useState(initialComments)
// Same logic as TanStack, but in separate file
// ...
}
// Logic split across multiple files
Feature Comparison
1. Type Safety
TanStack Start:
// Full end-to-end type safety
export const Route = createFileRoute('/products/$id')({
loader: async ({ params }) => {
const product = await db.products.findById(params.id) // params.id is typed!
return { product } // Return type inferred
},
component: ProductPage
})
function ProductPage() {
const { product } = Route.useLoaderData() // product is fully typed!
// ^ Type: Product (inferred from loader)
return <div>{product.name}</div> // Autocomplete works!
}
Next.js:
// Type safety requires manual typing
interface PageProps {
params: { id: string }
}
export default async function ProductPage({ params }: PageProps) {
const product = await db.products.findById(params.id)
// ^ Type: any (need to manually type)
return <div>{product.name}</div>
}
Winner: TanStack Start (automatic type inference)
2. Data Loading
TanStack Start:
export const Route = createFileRoute('/dashboard')({
loader: async () => {
// Runs on server
// Can access database directly
// Runs once per navigation
const data = await db.query()
return { data }
},
})
Next.js:
// Server Component
export default async function Dashboard() {
// Runs on server
// Can access database directly
// Re-runs on every request (unless cached)
const data = await db.query()
}
Winner: Tie (both support server-side data loading)
3. Client-Side Interactivity
TanStack Start:
function Component() {
// All React features work out of the box
const [state, setState] = useState()
useEffect(() => {})
const ref = useRef()
// Everything just works!
}
Next.js:
'use client' // Need this directive!
function Component() {
// Now React features work
const [state, setState] = useState()
// But creates separate client bundle
}
Winner: TanStack Start (no directives needed)
4. File-Based Routing
TanStack Start:
routes/
index.tsx → /
about.tsx → /about
blog/
index.tsx → /blog
$slug.tsx → /blog/:slug
products/
index.tsx → /products
$id.tsx → /products/:id
Next.js:
app/
page.tsx → /
about/
page.tsx → /about
blog/
page.tsx → /blog
[slug]/
page.tsx → /blog/:slug
Winner: Personal preference (both work well)
5. API Routes
TanStack Start:
// app/routes/api/users.ts
import { json } from '@tanstack/start'
export async function GET() {
const users = await db.users.findAll()
return json({ users })
}
export async function POST({ request }) {
const data = await request.json()
const user = await db.users.create(data)
return json({ user }, { status: 201 })
}
Next.js:
// app/api/users/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const users = await db.users.findAll()
return NextResponse.json({ users })
}
export async function POST(request: Request) {
const data = await request.json()
const user = await db.users.create(data)
return NextResponse.json({ user }, { status: 201 })
}
Winner: Tie (very similar syntax)
6. Performance
TanStack Start:
- Smaller bundle size (no Server Components overhead)
- Faster client-side navigation (SPA-like)
- Predictable performance
Next.js:
- Larger bundle size (Server Components runtime)
- Some client-side navigation overhead
- Excellent streaming support
Winner: Context-dependent (TanStack for SPAs, Next.js for content-heavy sites)
When to Use Each Framework
Choose TanStack Start When:
✓ You want simpler mental model
✓ Your team prefers traditional React (SPA-style)
✓ You need full type safety end-to-end
✓ You want everything in one file per route
✓ You're building a dashboard/admin panel
✓ You prefer explicit over implicit
✓ You don't need React Server Components
✓ You value developer experience simplicity
Choose Next.js When:
✓ You need best-in-class SEO
✓ You're building content-heavy sites (blogs, marketing)
✓ You want React Server Components benefits
✓ You need streaming SSR
✓ You want the largest ecosystem
✓ You need Vercel's deployment optimizations
✓ Your team is already familiar with Next.js
✓ You need image optimization out of the box
Miguel's Decision
After two weeks of building with both frameworks, Miguel made his choice:
"For my SaaS dashboard, TanStack Start was perfect:
// Everything I need in one place
export const Route = createFileRoute('/dashboard')({
loader: async () => {
const stats = await db.stats.get()
const users = await db.users.recent()
return { stats, users }
},
component: Dashboard,
})
function Dashboard() {
const { stats, users } = Route.useLoaderData()
const [filter, setFilter] = useState('all')
// Everything just works!
// No mental overhead
// Full TypeScript support
// Clean and simple
}
But for my blog, Next.js made sense:
// SEO is crucial for blogs
export const metadata = {
title: 'My Blog',
description: 'Great content'
}
// Server Components optimize content delivery
export default async function BlogPost() {
const post = await getPost()
return <Article post={post} />
}
Getting Started: Quick Start Guide
TanStack Start
# 1. Create project
npm create @tanstack/start@latest
# 2. Install dependencies
npm install
# 3. Run dev server
npm run dev
# 4. Build for production
npm run build
# 5. Deploy (Vercel/Netlify/etc)
npm run start
Your First Feature
// app/routes/todos/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
export const Route = createFileRoute('/todos/')({
loader: async () => {
const todos = await db.todos.findAll()
return { todos }
},
component: TodosPage
})
function TodosPage() {
const { todos: initialTodos } = Route.useLoaderData()
const [todos, setTodos] = useState(initialTodos)
const [newTodo, setNewTodo] = useState('')
const addTodo = async () => {
const todo = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text: newTodo })
}).then(r => r.json())
setTodos([...todos, todo])
setNewTodo('')
}
return (
<div className="p-8">
<h1 className="text-3xl font-bold mb-4">Todos</h1>
<div className="flex gap-2 mb-6">
<input
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
className="flex-1 p-2 border rounded"
placeholder="Add todo..."
/>
<button
onClick={addTodo}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Add
</button>
</div>
<ul className="space-y-2">
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</div>
)
}
function TodoItem({ todo }) {
const [done, setDone] = useState(todo.done)
const toggle = async () => {
await fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: JSON.stringify({ done: !done })
})
setDone(!done)
}
return (
<li className="flex items-center gap-2">
<input
type="checkbox"
checked={done}
onChange={toggle}
/>
<span className={done ? 'line-through' : ''}>
{todo.text}
</span>
</li>
)
}
The Ecosystem
TanStack Start Benefits From:
// TanStack Router (powerful routing)
import { createFileRoute, Link } from '@tanstack/react-router'
// TanStack Query (data fetching)
import { useQuery } from '@tanstack/react-query'
// TanStack Table (tables)
import { useReactTable } from '@tanstack/react-table'
// TanStack Form (forms)
import { useForm } from '@tanstack/react-form'
// All work together seamlessly!
Next.js Ecosystem:
// Next.js specific features
import Image from 'next/image'
import Link from 'next/link'
import { redirect } from 'next/navigation'
// Works great with:
// - Vercel deployment
// - Next Auth
// - Prisma
// - Tailwind
Real Developer Testimonials
Sarah (Switched from Next.js to TanStack Start):
"I love Next.js for marketing sites, but for our SaaS app, the Server/Client Component split was driving me crazy. With TanStack Start, I can think in React again. Everything in one place, full type safety, no magic directives."
David (Uses Both):
"Next.js for our public website (SEO matters). TanStack Start for our admin dashboard (simplicity matters). Each tool for the right job."
Elena (TanStack Start Early Adopter):
"Coming from traditional SPAs, TanStack Start felt immediately familiar. I got the benefits of SSR without relearning React. The loader pattern is so much clearer than Server Components."
Conclusion: The Right Tool for the Job
Both frameworks are excellent. The choice depends on your needs:
TanStack Start: Simplicity First
Perfect for:
- SaaS applications
- Admin dashboards
- Internal tools
- Teams who love traditional React
- Projects needing end-to-end type safety
- Developers who want simplicity
Next.js: Power First
Perfect for:
- Marketing websites
- Blogs and content sites
- E-commerce
- SEO-critical applications
- Teams invested in React Server Components
- Projects needing Vercel optimizations
Miguel's final advice:
"Don't choose based on hype. Choose based on your project:
Building a dashboard? Try TanStack Start.
Building a blog? Stick with Next.js.
Not sure? Build a small prototype in both.
The best framework is the one that makes your team productive."
Resources
TanStack Start
Next.js
Comparisons
"The future of React frameworks isn't one winner. It's the right tool for each job. Choose wisely, build confidently." - The Developer's Mantra
Ready to try TanStack Start? Your simpler React journey awaits! 🚀