TanStack Start Fundamentals - Building Full-Stack React Apps with Type Safety

TanStack Start Fundamentals - Building Full-Stack React Apps with Type Safety

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.

AI Agent
AI AgentFebruary 20, 2026
0 views
9 min read

Introduction

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.

The Problem Before TanStack Start

The Fragmentation Era

Before TanStack Start, building a modern full-stack React application meant making difficult choices:

Option 1: Separate Frontend & Backend

  • Frontend: React + React Router + TanStack Query
  • Backend: Node.js + Express/Fastify
  • Communication: REST API or GraphQL
  • Problem: Type safety breaks at the API boundary. You write TypeScript on both ends, but the contract between them is a string.

Option 2: Meta-Frameworks (Next.js, Remix)

  • Integrated routing and server functions
  • Better DX with file-based routing
  • Problem: Opinionated architecture, vendor lock-in, limited flexibility for data fetching patterns

Option 3: Full-Stack Frameworks (SvelteKit, Nuxt)

  • Similar trade-offs to meta-frameworks
  • Problem: Ecosystem fragmentation, different conventions per framework

The core issue: developers had to choose between type safety, developer experience, and architectural flexibility.

Why This Mattered

Consider a typical scenario: you're building a blog. You need to:

  1. Fetch posts from a database
  2. Handle pagination
  3. Manage loading/error states
  4. Cache data intelligently
  5. Invalidate cache on mutations
  6. Deploy both frontend and backend

With separate frontend/backend, you'd write:

ts
// 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.

The TanStack Start Solution

Why Tanner Linsley Created It

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:

  1. Preserves TanStack's philosophy: Headless, composable, framework-agnostic at iTs core
  2. Adds full-stack capabilities: Server functions, type-safe RPC, integrated routing
  3. Maintains flexibility: Not opinionated about UI, styling, or deployment
  4. Enables type safety: End-to-end TypeScript without code generation

TanStack Start launched as a spiritual successor to Remix, but with TanStack's DNA: minimal abstractions, maximum control.

The Evolution

React Router Era (2015-2020)

  • React Router established client-side routing patterns
  • Became the de facto standard for React SPAs

TanStack Query Era (2020-2022)

  • Solved the data fetching problem elegantly
  • Became the standard for server state management
  • Proved that headless libraries could dominate their category

TanStack Start Era (2023-Present)

  • Combines React Router + TanStack Query + Server Functions
  • Adds file-based routing and server-side rendering
  • Maintains the TanStack philosophy: do one thing well, compose with others

Core Fundamentals of TanStack Start

1. File-Based Routing

TanStack Start uses file-based routing similar to Next.js and Remix. Routes are defined by your file structure:

plaintext
app/
├── __root.tsx          # Root layout
├── index.tsx           # / route
├── blog/
│   ├── index.tsx       # /blog route
│   └── $postId.tsx     # /blog/:postId route
└── api/
    └── posts.ts        # Server function

Routes are automatically discovered and compiled. No manual route configuration needed.

2. Server Functions (RPC Pattern)

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:

ts
// 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:

ts
// 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:

  • Full type safety: TypeScript knows the return type of getPosts
  • No API contract to maintain
  • Automatic serialization/deserialization
  • Works with TanStack Query out of the box

3. Integrated TanStack Query

TanStack Query is built-in, not bolted-on. This means:

  • Server functions integrate seamlessly with useQuery and useMutation
  • Automatic cache invalidation patterns
  • Built-in loading/error/success states
  • Optimistic updates work naturally
ts
const { mutate: createPost } = useMutation({
  mutationFn: (newPost) => createPostServer(newPost),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] })
  }
})

4. Server-Side Rendering (SSR)

Routes can be rendered on the server, enabling:

  • SEO optimization
  • Faster initial page load
  • Reduced JavaScript sent to the browser
  • Streaming responses for better UX
ts
// 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>
}

5. Middleware & Hooks

TanStack Start provides lifecycle hooks for authentication, logging, and request/response transformation:

ts
// 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()
}

Building a Blog with TanStack Start, Velite & Shiki

Now let's build something practical: an MDX-powered blog using TanStack Start with Velite for content management and Shiki for syntax highlighting.

Project Structure

plaintext
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.json

Step 1: Configure Velite

Velite transforms your MDX files into structured data. Here's the configuration:

Velitevelite.config.ts
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:

  • Scans posts/**/*.mdx files
  • Extracts frontmatter metadata
  • Processes MDX with Shiki for syntax highlighting
  • Generates a slug from the title
  • Outputs structured data

Step 2: Create Server Functions for Posts

app/api/posts.server.ts
import { 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)
}

Step 3: Build the Blog Index Page

app/blog/index.tsx
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>
  )
}

Step 4: Build the Individual Post Page

app/blog/$slug.tsx
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>
  )
}

Step 5: Sample MDX Post

content/posts/tanstack-start-guide.mdx
---
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
```

Creating Your First Route

app/index.tsx
export default function HomePage() {
  return <h1>Welcome to TanStack Start</h1>
}

That's it! Your route is ready.

plaintext
 
## 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
}

2. Not Invalidating Cache After Mutations

After creating, updating, or deleting data, you must invalidate related queries:

ts
// ❌ Wrong - UI won't update
const { mutate } = useMutation({
  mutationFn: createPost,
})
 
// ✅ Correct - UI updates automatically
const { mutate } = useMutation({
  mutationFn: createPost,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] })
  },
})

3. Mixing Client and Server Code

TanStack Start's file-based approach can be confusing. Remember:

  • .server.ts files run only on the server
  • Regular .ts files run on both client and server (be careful with imports)
  • Use 'use client' directive for client-only code
ts
// ❌ 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>
}

4. Over-Fetching Data

With server functions, it's tempting to fetch everything. Use pagination and filtering:

ts
// ❌ 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,
  })
}

Best Practices

1. Organize Server Functions by Domain

Group related server functions together:

plaintext
app/
├── api/
│   ├── posts.server.ts      # Post-related functions
│   ├── comments.server.ts   # Comment-related functions
│   └── auth.server.ts       # Auth-related functions

2. Use TypeScript for End-to-End Safety

Define shared types in a common location:

types/index.ts
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:

ts
// app/api/posts.server.ts
import type { Post, CreatePostInput } from '../types'
 
export async function createPost(input: CreatePostInput): Promise<Post> {
  return db.posts.create(input)
}

3. Implement Proper Error Handling

ts
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
  }
}

4. Cache Strategy with TanStack Query

Use appropriate cache times based on data volatility:

ts
// 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,
})

5. Optimize Bundle Size

Use dynamic imports for heavy components:

ts
import { lazy, Suspense } from 'react'
 
const HeavyEditor = lazy(() => import('./HeavyEditor'))
 
export default function Page() {
  return (
    <Suspense fallback={<div>Loading editor...</div>}>
      <HeavyEditor />
    </Suspense>
  )
}

When NOT to Use TanStack Start

1. Static Sites

If you're building a purely static site (blog, documentation), Next.js with static generation might be simpler.

2. Simple CRUD Apps

For very simple applications, the overhead of TanStack Start might not be justified. Consider a simpler framework.

3. Strict SEO Requirements

While TanStack Start supports SSR, if SEO is critical and complex, Next.js has more mature solutions.

4. Team Unfamiliar with React

TanStack Start requires solid React knowledge. If your team is new to React, consider a more beginner-friendly framework.

Conclusion

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:

  • File-based routing for organization
  • Server functions for type-safe data fetching
  • TanStack Query for intelligent caching
  • Velite for content management
  • Shiki for beautiful code highlighting

Start small, build incrementally, and leverage the TanStack ecosystem's maturity. The learning curve is worth it.

Next steps:

  1. Clone the TanStack Start starter template
  2. Build a simple page with server functions
  3. Add TanStack Query for data fetching
  4. Integrate Velite for content management
  5. Deploy to your preferred platform (Vercel, Netlify, self-hosted)

The full-stack React future is here. Welcome to TanStack Start.


Related Posts