Discover why TanStack Start exists, iTs evolution from the React Router ecosystem, and how to build a production-ready MDX blog with integrated query management and server functions.

The JavaScript ecosystem has a problem: building full-stack applications requires stitching together multiple frameworks, each with iTs own philosophy, conventions, and learning curve. You pick a frontend framework (React), a router (React Router), a data fetching library (TanStack Query), a server framework (Express, Remix, Next.js), and suddenly you're managing API contracts, type safety across boundaries, and deployment complexity.
TanStack Start exists to solve this fragmentation. It's not just another framework—it's an opinionated, type-safe full-stack meta-framework built on proven technologies from the TanStack ecosystem. In this article, we'll explore why it was created, how it evolved, understand iTs core fundamentals, and build a practical MDX-powered blog to see it in action.
Before TanStack Start, building a modern full-stack React application meant making difficult choices:
Option 1: Separate Frontend & Backend
Option 2: Meta-Frameworks (Next.js, Remix)
Option 3: Full-Stack Frameworks (SvelteKit, Nuxt)
The core issue: developers had to choose between type safety, developer experience, and architectural flexibility.
Consider a typical scenario: you're building a blog. You need to:
With separate frontend/backend, you'd write:
// Frontend
const { data, isLoading } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetch(`/api/posts?page=${page}`).then(r => r.json())
})
// Backend
app.get('/api/posts', (req, res) => {
const posts = db.query(...)
res.json(posts)
})The type of data is unknown. You'd need to manually validate it, write Zod schemas, or use code generation tools. It's friction.
Tanner Linsley, creator of TanStack Query and React Router, recognized that the TanStack ecosystem had all the pieces needed for a cohesive full-stack experience. Instead of forcing developers to choose between frameworks, why not create a meta-framework that:
TanStack Start launched as a spiritual successor to Remix, but with TanStack's DNA: minimal abstractions, maximum control.
React Router Era (2015-2020)
TanStack Query Era (2020-2022)
TanStack Start Era (2023-Present)
TanStack Start uses file-based routing similar to Next.js and Remix. Routes are defined by your file structure:
app/
├── __root.tsx # Root layout
├── index.tsx # / route
├── blog/
│ ├── index.tsx # /blog route
│ └── $postId.tsx # /blog/:postId route
└── api/
└── posts.ts # Server functionRoutes are automatically discovered and compiled. No manual route configuration needed.
Instead of building REST APIs, TanStack Start uses server functions—TypeScript functions that run on the server but are called from the client as if they were local:
// app/api/posts.server.ts
export async function getPosts(page: number) {
const posts = await db.posts.findMany({
skip: (page - 1) * 10,
take: 10,
})
return posts
}On the client:
// app/blog/index.tsx
import { getPosts } from '../api/posts.server'
import { useQuery } from '@tanstack/react-query'
export default function BlogPage() {
const { data: posts } = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts(page)
})
return <div>{/* render posts */}</div>
}Why this is powerful:
getPostsTanStack Query is built-in, not bolted-on. This means:
useQuery and useMutationconst { mutate: createPost } = useMutation({
mutationFn: (newPost) => createPostServer(newPost),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
}
})Routes can be rendered on the server, enabling:
// app/blog/$postId.tsx
export async function loader({ params }) {
return getPost(params.postId)
}
export default function PostPage() {
const post = useLoaderData()
return <article>{post.content}</article>
}TanStack Start provides lifecycle hooks for authentication, logging, and request/response transformation:
// app/__root.tsx
export const middleware = [authMiddleware, loggingMiddleware]
async function authMiddleware(req, res, next) {
const user = await verifyToken(req.headers.authorization)
req.user = user
next()
}Now let's build something practical: an MDX-powered blog using TanStack Start with Velite for content management and Shiki for syntax highlighting.
blog-app/
├── app/
│ ├── __root.tsx
│ ├── index.tsx
│ ├── blog/
│ │ ├── index.tsx
│ │ └── $slug.tsx
│ └── api/
│ └── posts.server.ts
├── content/
│ └── posts/
│ ├── tanstack-start-guide.mdx
│ └── react-patterns.mdx
├── velite.config.ts
├── tsconfig.json
└── package.jsonVelite transforms your MDX files into structured data. Here's the configuration:
import { defineConfig, s } from 'velite'
import { rehypeShiki } from '@shikijs/rehype'
export default defineConfig({
collections: {
posts: {
name: 'Post',
pattern: 'posts/**/*.mdx',
schema: s
.object({
title: s.string(),
excerpt: s.string(),
published_date: s.string().transform(v => new Date(v)),
updated_date: s.string().transform(v => new Date(v)),
tags: s.array(s.string()),
categories: s.array(s.string()),
content: s.mdx(),
slug: s.slug('title'),
})
.strict(),
transform: async (document) => ({
...document,
permalink: `/blog/${document.slug}`,
}),
},
},
mdx: {
rehypePlugins: [
[
rehypeShiki,
{
theme: 'github-dark',
fallbackLanguage: 'plaintext',
},
],
],
},
})This configuration:
posts/**/*.mdx filesimport { posts } from '@/.velite'
export async function getAllPosts() {
return posts
.filter(post => post.published)
.sort((a, b) =>
new Date(b.published_date).getTime() -
new Date(a.published_date).getTime()
)
}
export async function getPostBySlug(slug: string) {
const post = posts.find(p => p.slug === slug)
if (!post) {
throw new Error(`Post not found: ${slug}`)
}
return post
}
export async function getRelatedPosts(slug: string, limit: number = 3) {
const post = await getPostBySlug(slug)
return posts
.filter(p =>
p.slug !== slug &&
p.tags.some(tag => post.tags.includes(tag))
)
.slice(0, limit)
}import { useQuery } from '@tanstack/react-query'
import { getAllPosts } from '../api/posts.server'
import Link from '@tanstack/react-router'
export default function BlogPage() {
const { data: posts, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: () => getAllPosts(),
})
if (isLoading) return <div>Loading posts...</div>
return (
<div className="max-w-4xl mx-auto py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="space-y-8">
{posts?.map(post => (
<article
key={post.slug}
className="border-b pb-8 last:border-b-0"
>
<Link
to={`/blog/${post.slug}`}
className="hover:text-blue-600"
>
<h2 className="text-2xl font-semibold mb-2">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<time dateTime={post.published_date.toISOString()}>
{post.published_date.toLocaleDateString()}
</time>
<div className="flex gap-2">
{post.tags.map(tag => (
<span
key={tag}
className="bg-gray-100 px-2 py-1 rounded text-xs"
>
{tag}
</span>
))}
</div>
</div>
</article>
))}
</div>
</div>
)
}import { useParams } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { getPostBySlug, getRelatedPosts } from '../api/posts.server'
export default function PostPage() {
const { slug } = useParams({ from: '/blog/$slug' })
const { data: post, isLoading } = useQuery({
queryKey: ['post', slug],
queryFn: () => getPostBySlug(slug),
})
const { data: relatedPosts } = useQuery({
queryKey: ['relatedPosts', slug],
queryFn: () => getRelatedPosts(slug),
enabled: !!post,
})
if (isLoading) return <div>Loading post...</div>
if (!post) return <div>Post not found</div>
return (
<article className="max-w-3xl mx-auto py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center justify-between text-gray-600 mb-6">
<time dateTime={post.published_date.toISOString()}>
{post.published_date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<div className="flex gap-2">
{post.tags.map(tag => (
<span
key={tag}
className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
</div>
<p className="text-lg text-gray-700">{post.excerpt}</p>
</header>
{/* MDX Content with Shiki syntax highlighting */}
<div className="prose prose-invert max-w-none mb-12">
{post.content}
</div>
{/* Related Posts */}
{relatedPosts && relatedPosts.length > 0 && (
<aside className="mt-12 pt-8 border-t">
<h3 className="text-2xl font-semibold mb-6">Related Posts</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedPosts.map(relatedPost => (
<a
key={relatedPost.slug}
href={`/blog/${relatedPost.slug}`}
className="p-4 border rounded-lg hover:shadow-lg transition"
>
<h4 className="font-semibold mb-2">{relatedPost.title}</h4>
<p className="text-sm text-gray-600">{relatedPost.excerpt}</p>
</a>
))}
</div>
</aside>
)}
</article>
)
}---
title: Getting Started with TanStack Start
excerpt: Learn the fundamentals of TanStack Start and build your first full-stack application.
published_date: "2026-02-15"
updated_date: "2026-02-15"
tags:
- programming
- software-engineer
categories:
- Tutorials
---
## Introduction
TanStack Start is a full-stack meta-framework built on React Router and TanStack Query.
## Installation
```bash
npm install @tanstack/start @tanstack/react-router @tanstack/react-query
```export default function HomePage() {
return <h1>Welcome to TanStack Start</h1>
}That's it! Your route is ready.
## Common Mistakes & Pitfalls
### 1. Forgetting the `.server` Extension
Server functions must end with `.server.ts` or `.server.tsx`. Without it, they'll be bundled into the client code, exposing sensitive logic.
```ts
// ❌ Wrong - will be sent to browser
export async function getSecretKey() {
return process.env.SECRET_KEY
}
// ✅ Correct - stays on server
// posts.server.ts
export async function getSecretKey() {
return process.env.SECRET_KEY
}After creating, updating, or deleting data, you must invalidate related queries:
// ❌ Wrong - UI won't update
const { mutate } = useMutation({
mutationFn: createPost,
})
// ✅ Correct - UI updates automatically
const { mutate } = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})TanStack Start's file-based approach can be confusing. Remember:
.server.ts files run only on the server.ts files run on both client and server (be careful with imports)'use client' directive for client-only code// ❌ Wrong - database code in client component
export default function Page() {
const posts = db.posts.findMany() // ❌ Runs on client!
return <div>{posts}</div>
}
// ✅ Correct - use server function
export async function getPosts() {
return db.posts.findMany()
}
export default function Page() {
const { data: posts } = useQuery({
queryFn: () => getPosts()
})
return <div>{posts}</div>
}With server functions, it's tempting to fetch everything. Use pagination and filtering:
// ❌ Wrong - fetches all posts every time
export async function getPosts() {
return db.posts.findMany()
}
// ✅ Correct - paginated and filtered
export async function getPosts(page: number = 1, limit: number = 10) {
return db.posts.findMany({
skip: (page - 1) * limit,
take: limit,
})
}Group related server functions together:
app/
├── api/
│ ├── posts.server.ts # Post-related functions
│ ├── comments.server.ts # Comment-related functions
│ └── auth.server.ts # Auth-related functionsDefine shared types in a common location:
export interface Post {
id: string
title: string
content: string
published_date: Date
tags: string[]
}
export interface CreatePostInput {
title: string
content: string
tags: string[]
}Then use them in both server and client code:
// app/api/posts.server.ts
import type { Post, CreatePostInput } from '../types'
export async function createPost(input: CreatePostInput): Promise<Post> {
return db.posts.create(input)
}export async function getPost(id: string) {
try {
const post = await db.posts.findUnique({ where: { id } })
if (!post) {
throw new Error('Post not found')
}
return post
} catch (error) {
console.error('Failed to fetch post:', error)
throw error
}
}Use appropriate cache times based on data volatility:
// Static content - cache for 1 hour
useQuery({
queryKey: ['posts'],
queryFn: getPosts,
staleTime: 1000 * 60 * 60,
})
// User-specific data - cache for 5 minutes
useQuery({
queryKey: ['user', userId],
queryFn: () => getUser(userId),
staleTime: 1000 * 60 * 5,
})
// Real-time data - no caching
useQuery({
queryKey: ['notifications'],
queryFn: getNotifications,
staleTime: 0,
})Use dynamic imports for heavy components:
import { lazy, Suspense } from 'react'
const HeavyEditor = lazy(() => import('./HeavyEditor'))
export default function Page() {
return (
<Suspense fallback={<div>Loading editor...</div>}>
<HeavyEditor />
</Suspense>
)
}If you're building a purely static site (blog, documentation), Next.js with static generation might be simpler.
For very simple applications, the overhead of TanStack Start might not be justified. Consider a simpler framework.
While TanStack Start supports SSR, if SEO is critical and complex, Next.js has more mature solutions.
TanStack Start requires solid React knowledge. If your team is new to React, consider a more beginner-friendly framework.
TanStack Start solves a real problem: the fragmentation of full-stack JavaScript development. By combining React Router, TanStack Query, and server functions, it provides a cohesive, type-safe development experience without sacrificing flexibility.
The blog example we built demonstrates the core patterns:
Start small, build incrementally, and leverage the TanStack ecosystem's maturity. The learning curve is worth it.
Next steps:
The full-stack React future is here. Welcome to TanStack Start.