Roberto Tomé

ROBERTO TOMÉ

Complete TanStack Router Tutorial: Building a Todo App
Tutorials

Complete TanStack Router Tutorial: Building a Todo App

Complete TanStack Router Tutorial: Building a Todo App

Welcome to this comprehensive beginner-friendly tutorial on TanStack Router! We’ll build a fully functional Todo application that demonstrates all of TanStack Router’s core features. By the end of this tutorial, you’ll have a solid understanding of how to use TanStack Router for type-safe routing in React applications.

Introduction

TanStack Router is a modern, type-safe routing solution for React, created by Tanner Linsley (the creator of TanStack Query). It represents a significant evolution from traditional routing libraries, offering powerful features that make building complex applications both safer and more enjoyable.
 

What Makes TanStack Router Different?

TanStack Router stands out from React Router in several key ways:

Type Safety: TanStack Router provides 100% inferred TypeScript support with automatically generated type definitions for routes, parameters, and search queries. This catches routing errors at compile time rather than runtime.

File-Based Routing: While React Router uses declarative JSX configuration, TanStack Router supports both file-based and code-based routing, with automatic route generation from your folder structure.

Built-in Data Loading: Unlike React Router which requires external libraries, TanStack Router includes integrated SWR caching, route loaders, and intelligent preloading capabilities.

Advanced Search Parameter Handling: TanStack Router offers sophisticated search parameter parsing and validation with automatic JSON serialization and type-safe access.
 

Key Advantages

  1. Developer Experience: Comprehensive TypeScript integration eliminates entire categories of routing bugs
  2. Performance: Automatic code splitting, intelligent preloading, and built-in caching
  3. Modern Architecture: Designed from the ground up for modern React patterns and best practices
  4. Powerful Features: Route-level error boundaries, nested layouts, and context sharing

While React Router excels in simplicity and proven reliability, TanStack Router leads in modern development experience and type safety. It’s particularly compelling for TypeScript-heavy applications where compile-time guarantees provide significant value.
 

Setup

Let’s start by setting up a new React project with TanStack Router. We’ll use Vite for fast development and modern tooling.

The fastest way to get started is using the official starter template:

npx create-tsrouter-app@latest todo-app --template file-router --tailwind
cd todo-app
npm run dev

Install Zod for TypeScript data validation:

npm install zod@^3.23.8
npm install @tanstack/zod-adapter

Your project structure should look like this:

src/
├── routes/           # Route files (generated by default)
├── lib/             # Utilities and API functions
├── components/      # Reusable components
├── main.tsx        # App entry point
└── App.css

 

Basic Routes

Now let’s create our basic route structure. TanStack Router uses file-based routing, where the file structure in your routes directory maps directly to URL paths.

Step 1: Create the Root Route

Create src/routes/__root.tsx (note the double underscores):

import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'

const RootComponent = () => (
  <>
    <div className="p-4 border-b">
      <h1 className="text-2xl font-bold mb-4">Todo App</h1>
      <nav className="flex gap-4">
        <Link to="/" className="[&.active]:font-bold text-blue-600 hover:underline">
          Home
        </Link>
        <Link to="/todos" className="[&.active]:font-bold text-blue-600 hover:underline">
          Todos
        </Link>
        <Link to="/about" className="[&.active]:font-bold text-blue-600 hover:underline">
          About
        </Link>
      </nav>
    </div>
    <div className="p-4">
      <Outlet />
    </div>
    <TanStackRouterDevtools />
  </>
)

export const Route = createRootRoute({
  component: RootComponent,
})

Step 2: Create the Home Page

Create src/routes/index.tsx:

import { createFileRoute } from '@tanstack/react-router'

function Home() {
  return (
    <div>
      <h2 className="text-xl font-semibold mb-4">Welcome to Todo App</h2>
      <p className="text-gray-600">
        This is a demo application showcasing TanStack Router features.
        Navigate to the Todos section to start managing your tasks!
      </p>
    </div>
  )
}

export const Route = createFileRoute('/')({
  component: Home,
})

Step 3: Create the About Page

Create src/routes/about.tsx:

import { createFileRoute } from '@tanstack/react-router'

function About() {
  return (
    <div>
      <h2 className="text-xl font-semibold mb-4">About This App</h2>
      <p className="text-gray-600 mb-4">
        This Todo application demonstrates the powerful features of TanStack Router:
      </p>
      <ul className="list-disc pl-6 text-gray-600 space-y-2">
        <li>Type-safe routing with automatic TypeScript inference</li>
        <li>File-based routing with nested layouts</li>
        <li>Built-in data loading and caching</li>
        <li>Error boundaries and 404 handling</li>
        <li>Search parameter validation</li>
      </ul>
    </div>
  )
}

export const Route = createFileRoute('/about')({
  component: About,
})

Step 4: Set Up the Router

Update your src/main.tsx:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import "./styles.css";

// Import the generated route tree
import { routeTree } from "./routeTree.gen";

// Create a new router instance
const router = createRouter({ routeTree });

// Register the router instance for type safety
declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router;
  }
}

// Render the app
const rootElement = document.getElementById("app")!;
createRoot(rootElement).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>,
);

At this point, you should be able to run npm run dev and see your basic routing working with navigation between Home, Todos (we’ll create this next), and About pages.
 

Nested Routes

Now let’s implement nested routes for our Todo functionality. We’ll create a todos section with a list page and individual todo detail pages using the pattern /todos/:todoId.

Step 1: Create Types and Mock API

First, let’s create our data types and mock API. Create src/lib/types.ts:

export interface Todo {
  id: number
  title: string
  description: string
  completed: boolean
  createdAt: string
}

export interface CreateTodoData {
  title: string
  description: string
}

export interface UpdateTodoData {
  title?: string
  description?: string
  completed?: boolean
}

Create src/lib/api.ts for our mock API functions:

import type { Todo, CreateTodoData, UpdateTodoData } from "./types";

// Mock data
let todos: Todo[] = [
  {
    id: 1,
    title: 'Learn TanStack Router',
    description: 'Complete the tutorial and understand all core concepts',
    completed: false,
    createdAt: '2024-01-01T10:00:00Z',
  },
  {
    id: 2,
    title: 'Build a React app',
    description: 'Create a new React application with modern routing',
    completed: true,
    createdAt: '2024-01-02T14:30:00Z',
  },
  {
    id: 3,
    title: 'Deploy to production',
    description: 'Deploy the finished app to Vercel or Netlify',
    completed: false,
    createdAt: '2024-01-03T09:15:00Z',
  },
]

let nextId = 4

// Simulate network delay
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

export const api = {
  // Fetch all todos
  async getTodos(): Promise<Todo[]> {
    await delay(300)
    return [...todos]
  },

  // Fetch a single todo
  async getTodo(id: number): Promise<Todo | null> {
    await delay(200)
    return todos.find(todo => todo.id === id) || null
  },

  // Create a new todo
  async createTodo(data: CreateTodoData): Promise<Todo> {
    await delay(400)
    const newTodo: Todo = {
      id: nextId++,
      title: data.title,
      description: data.description,
      completed: false,
      createdAt: new Date().toISOString(),
    }
    todos.push(newTodo)
    return newTodo
  },

  // Update a todo
  async updateTodo(id: number, data: UpdateTodoData): Promise<Todo | null> {
    await delay(300)
    const index = todos.findIndex(todo => todo.id === id)
    if (index === -1) return null

    todos[index] = { ...todos[index], ...data }
    return todos[index]
  },

  // Delete a todo
  async deleteTodo(id: number): Promise<boolean> {
    await delay(250)
    const index = todos.findIndex(todo => todo.id === id)
    if (index === -1) return false

    todos.splice(index, 1)
    return true
  },
}

Step 2: Create Todos Layout Route

Create the directory structure for nested todos routes:

mkdir src/routes/todos

Create src/routes/todos/route.tsx (this is the layout route):

import { createFileRoute, Outlet } from '@tanstack/react-router'

function TodosLayout() {
  return (
    <div>
      <h2 className="text-xl font-semibold mb-6">Todo Management</h2>
      <Outlet />
    </div>
  )
}

export const Route = createFileRoute('/todos')({
  component: TodosLayout,
})

Step 3: Create Todos List Page

Create src/routes/todos/index.tsx:

import { createFileRoute, Link } from '@tanstack/react-router'
import { api } from '../../lib/api'

function TodosList() {
  const todos = Route.useLoaderData()

  return (
    <div>
      <div className="flex justify-between items-center mb-6">
        <h3 className="text-lg font-medium">Your Todos</h3>
        <Link
          to="/todos/new"
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          Add New Todo
        </Link>
      </div>

      {todos.length === 0 ? (
        <p className="text-gray-500">No todos yet. Create your first one!</p>
      ) : (
        <div className="space-y-4">
          {todos.map(todo => (
            <div
              key={todo.id}
              className="border p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow"
            >
              <div className="flex items-start justify-between">
                <div className="flex-1">
                  <h4 className={`font-medium ${todo.completed ? 'line-through text-gray-500' : ''}`}>
                    {todo.title}
                  </h4>
                  <p className="text-gray-600 text-sm mt-1">{todo.description}</p>
                  <span className={`inline-block px-2 py-1 text-xs rounded mt-2 ${
                    todo.completed
                      ? 'bg-green-100 text-green-800'
                      : 'bg-yellow-100 text-yellow-800'
                  }`}>
                    {todo.completed ? 'Completed' : 'Pending'}
                  </span>
                </div>
                <Link
                  to="/todos/$todoId"
                  params={{ todoId: todo.id.toString() }}
                  className="text-blue-600 hover:underline ml-4"
                >
                  View Details
                </Link>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

export const Route = createFileRoute('/todos/')({
  loader: () => api.getTodos(),
  component: TodosList,
})

Step 4: Create Individual Todo Detail Page

Create src/routes/todos/$todoId.tsx for the nested route with parameter:

import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { api } from '../../lib/api'

function TodoDetail() {
  const navigate = useNavigate()
  const todo = Route.useLoaderData()
  const { todoId } = Route.useParams()

  const handleDelete = async () => {
    if (confirm('Are you sure you want to delete this todo?')) {
      await api.deleteTodo(Number(todoId))
      navigate({ to: '/todos' })
    }
  }

  const handleToggleComplete = async () => {
    await api.updateTodo(Number(todoId), { completed: !todo.completed })
    // Invalidate and reload the current route to get fresh data
    navigate({ to: '/todos/$todoId', params: { todoId } })
  }

  if (!todo) {
    return (
      <div className="text-center py-8">
        <h3 className="text-lg font-medium text-gray-900">Todo not found</h3>
        <Link to="/todos" className="text-blue-600 hover:underline">
          Back to todos
        </Link>
      </div>
    )
  }

  return (
    <div>
      <div className="mb-6">
        <Link to="/todos" className="text-blue-600 hover:underline">
          ← Back to todos
        </Link>
      </div>

      <div className="bg-white border rounded-lg p-6 shadow-sm">
        <div className="flex items-start justify-between mb-4">
          <h3 className={`text-xl font-semibold ${todo.completed ? 'line-through text-gray-500' : ''}`}>
            {todo.title}
          </h3>
          <span className={`px-3 py-1 text-sm rounded-full ${
            todo.completed
              ? 'bg-green-100 text-green-800'
              : 'bg-yellow-100 text-yellow-800'
          }`}>
            {todo.completed ? 'Completed' : 'Pending'}
          </span>
        </div>

        <p className="text-gray-700 mb-4">{todo.description}</p>

        <p className="text-sm text-gray-500 mb-6">
          Created: {new Date(todo.createdAt).toLocaleDateString()}
        </p>

        <div className="flex gap-3">
          <button
            onClick={handleToggleComplete}
            className={`px-4 py-2 rounded ${
              todo.completed
                ? 'bg-yellow-600 text-white hover:bg-yellow-700'
                : 'bg-green-600 text-white hover:bg-green-700'
            }`}
          >
            Mark as {todo.completed ? 'Pending' : 'Completed'}
          </button>

          <Link
            to="/todos/$todoId/edit"
            params={{ todoId: todoId }}
            className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
          >
            Edit
          </Link>

          <button
            onClick={handleDelete}
            className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
          >
            Delete
          </button>
        </div>
      </div>
    </div>
  )
}

export const Route = createFileRoute('/todos/$todoId')({
  loader: ({ params }) => api.getTodo(Number(params.todoId)),
  component: TodoDetail,
})

This creates a powerful nested routing structure where:

  • /todos shows the layout with a list of todos
  • /todos/123 shows the details for todo with ID 123
  • The layout persists across both routes, demonstrating TanStack Router’s nested routing capabilities

 

Data Loading

TanStack Router provides powerful built-in data loading capabilities through route loaders. These functions run before a route renders, ensuring data is available immediately without loading states in your components.

Understanding Route Loaders

Route loaders are functions that execute when a route match is loaded. They provide several key benefits:

  • No “flash of loading” states - data is ready when components render
  • Parallel data fetching - multiple loaders run simultaneously
  • Built-in SWR caching - automatic caching with stale-while-revalidate strategy
  • Type safety - full TypeScript integration with inferred return types

Step 1: Basic Loader Implementation

We’ve already seen basic loaders in our previous examples. Let’s enhance them with more advanced features. Update src/routes/todos/index.tsx:

import { createFileRoute, Link } from '@tanstack/react-router'
import { api } from '../../lib/api'

function TodosList() {
  const todos = Route.useLoaderData()

  return (
    <div>
      <div className="flex justify-between items-center mb-6">
        <h3 className="text-lg font-medium">Your Todos ({todos.length})</h3>
        <Link
          to="/todos/new"
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          Add New Todo
        </Link>
      </div>

      {todos.length === 0 ? (
        <p className="text-gray-500">No todos yet. Create your first one!</p>
      ) : (
        <div className="space-y-4">
          {todos.map(todo => (
            <div
              key={todo.id}
              className="border p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow"
            >
              <div className="flex items-start justify-between">
                <div className="flex-1">
                  <h4 className={`font-medium ${todo.completed ? 'line-through text-gray-500' : ''}`}>
                    {todo.title}
                  </h4>
                  <p className="text-gray-600 text-sm mt-1">{todo.description}</p>
                  <span className={`inline-block px-2 py-1 text-xs rounded mt-2 ${
                    todo.completed
                      ? 'bg-green-100 text-green-800'
                      : 'bg-yellow-100 text-yellow-800'
                  }`}>
                    {todo.completed ? 'Completed' : 'Pending'}
                  </span>
                </div>
                <div className="flex gap-2">
                  <Link
                    to="/todos/$todoId"
                    params={{ todoId: todo.id.toString() }}
                    className="text-blue-600 hover:underline"
                  >
                    View
                  </Link>
                  <Link
                    to="/todos/$todoId/edit"
                    params={{ todoId: todo.id.toString() }}
                    className="text-green-600 hover:underline"
                  >
                    Edit
                  </Link>
                </div>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

export const Route = createFileRoute('/todos/')({
  // Loader function with caching
  loader: async () => {
    console.log('Loading todos...')
    return api.getTodos()
  },

  // Configure caching behavior
  staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes

  component: TodosList,
})

Step 2: Loader with Search Parameters

Let’s add filtering capabilities using search parameters. Update src/routes/todos/index.tsx to support filtering:

import { createFileRoute, Link } from '@tanstack/react-router'
import { z } from 'zod'
import { zodValidator } from '@tanstack/zod-adapter'
import { api } from '../../lib/api'

// Define search parameter schema
const todosSearchSchema = z.object({
  filter: z.enum(['all', 'completed', 'pending']).default('all'),
  search: z.string().default(''),
})

type TodosSearch = z.infer<typeof todosSearchSchema>

function TodosList() {
  const todos = Route.useLoaderData()
  const { filter, search } = Route.useSearch()

  return (
    <div>
      <div className="flex justify-between items-center mb-6">
        <h3 className="text-lg font-medium">Your Todos ({todos.length})</h3>
        <Link
          to="/todos/new"
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          Add New Todo
        </Link>
      </div>

      {/* Filter Controls */}
      <div className="mb-6 p-4 bg-gray-50 rounded-lg">
        <div className="flex gap-4 items-center">
          <div>
            <label className="text-sm font-medium text-gray-700">Filter:</label>
            <div className="flex gap-2 mt-1">
              {(['all', 'completed', 'pending'] as const).map(filterOption => (
                <Link
                  key={filterOption}
                  to="/todos"
                  search={{ filter: filterOption, search }}
                  className={`px-3 py-1 text-sm rounded ${
                    filter === filterOption
                      ? 'bg-blue-600 text-white'
                      : 'bg-white border hover:bg-gray-50'
                  }`}
                >
                  {filterOption.charAt(0).toUpperCase() + filterOption.slice(1)}
                </Link>
              ))}
            </div>
          </div>

          <div className="flex-1">
            <label className="text-sm font-medium text-gray-700">Search:</label>
            <input
              type="text"
              value={search}
              onChange={(e) => {
                // Navigate with updated search parameter
                window.history.pushState(
                  {},
                  '',
                  `/todos?filter=${filter}&search=${encodeURIComponent(e.target.value)}`
                )
                window.location.reload() // In a real app, you'd use proper navigation
              }}
              placeholder="Search todos..."
              className="mt-1 block w-full px-3 py-1 border border-gray-300 rounded text-sm"
            />
          </div>
        </div>
      </div>

      {todos.length === 0 ? (
        <p className="text-gray-500">No todos match your criteria.</p>
      ) : (
        <div className="space-y-4">
          {todos.map(todo => (
            <div
              key={todo.id}
              className="border p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow"
            >
              <div className="flex items-start justify-between">
                <div className="flex-1">
                  <h4 className={`font-medium ${todo.completed ? 'line-through text-gray-500' : ''}`}>
                    {todo.title}
                  </h4>
                  <p className="text-gray-600 text-sm mt-1">{todo.description}</p>
                  <span className={`inline-block px-2 py-1 text-xs rounded mt-2 ${
                    todo.completed
                      ? 'bg-green-100 text-green-800'
                      : 'bg-yellow-100 text-yellow-800'
                  }`}>
                    {todo.completed ? 'Completed' : 'Pending'}
                  </span>
                </div>
                <div className="flex gap-2">
                  <Link
                    to="/todos/$todoId"
                    params={{ todoId: todo.id.toString() }}
                    className="text-blue-600 hover:underline"
                  >
                    View
                  </Link>
                  <Link
                    to="/todos/$todoId/edit"
                    params={{ todoId: todo.id.toString() }}
                    className="text-green-600 hover:underline"
                  >
                    Edit
                  </Link>
                </div>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

export const Route = createFileRoute('/todos/')({
  // Validate search parameters
  validateSearch: zodValidator(todosSearchSchema),

  // Use search parameters as loader dependencies
  loaderDeps: ({ search: { filter, search } }) => ({ filter, search }),

  // Loader function that uses search parameters
  loader: async ({ deps: { filter, search } }) => {
    console.log('Loading todos with filter:', filter, 'search:', search)

    let todos = await api.getTodos()

    // Apply filtering
    if (filter !== 'all') {
      todos = todos.filter(todo =>
        filter === 'completed' ? todo.completed : !todo.completed
      )
    }

    // Apply search
    if (search) {
      const searchLower = search.toLowerCase()
      todos = todos.filter(todo =>
        todo.title.toLowerCase().includes(searchLower) ||
        todo.description.toLowerCase().includes(searchLower)
      )
    }

    return todos
  },

  // Configure caching - reload when dependencies change
  staleTime: 2 * 60 * 1000, // 2 minutes

  component: TodosList,
})

Step 3: Advanced Loader Features

Let’s add error handling and loading states to our individual todo route. Update src/routes/todos/$todoId.tsx:

import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { api } from '../../lib/api'

function TodoDetail() {
  const navigate = useNavigate()
  const todo = Route.useLoaderData()
  const { todoId } = Route.useParams()

  // ... component logic (same as before)
}

// Loading component displayed while loader runs
function TodoDetailPending() {
  return (
    <div className="animate-pulse">
      <div className="mb-6">
        <div className="h-4 bg-gray-200 rounded w-20"></div>
      </div>
      <div className="bg-white border rounded-lg p-6 shadow-sm">
        <div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
        <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
        <div className="h-4 bg-gray-200 rounded w-2/3 mb-4"></div>
        <div className="h-4 bg-gray-200 rounded w-32 mb-6"></div>
        <div className="flex gap-3">
          <div className="h-8 bg-gray-200 rounded w-20"></div>
          <div className="h-8 bg-gray-200 rounded w-16"></div>
          <div className="h-8 bg-gray-200 rounded w-16"></div>
        </div>
      </div>
    </div>
  )
}

// Error component displayed when loader fails
function TodoDetailError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="text-center py-8">
      <h3 className="text-lg font-medium text-red-600 mb-2">Failed to load todo</h3>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <div className="space-x-3">
        <button
          onClick={reset}
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          Try Again
        </button>
        <Link to="/todos" className="text-blue-600 hover:underline">
          Back to todos
        </Link>
      </div>
    </div>
  )
}

export const Route = createFileRoute('/todos/$todoId')({
  // Loader with error handling
  loader: async ({ params }) => {
    const todo = await api.getTodo(Number(params.todoId))
    if (!todo) {
      throw new Error(`Todo with ID ${params.todoId} not found`)
    }
    return todo
  },

  // Configure loading and error states
  pendingComponent: TodoDetailPending,
  errorComponent: TodoDetailError,

  // Show pending component after 500ms
  pendingMs: 500,
  pendingMinMs: 200,

  // Configure caching
  staleTime: 30 * 1000, // 30 seconds

  component: TodoDetail,
})

This demonstrates TanStack Router’s comprehensive data loading features:

  • Automatic caching with configurable staleness
  • Search parameter integration with type-safe validation
  • Loading states with customizable pending components
  • Error handling with recovery mechanisms
  • Dependency tracking for efficient cache invalidation

The router coordinates all data loading, ensuring optimal performance and user experience.
 

Mutations/Actions

TanStack Router focuses primarily on routing and data loading, but it integrates seamlessly with external mutation libraries. For our Todo app, we’ll implement mutations using the router’s invalidation system to keep data in sync.

Understanding Router Invalidation

When data changes through mutations, we need to tell TanStack Router to refresh cached data. The router.invalidate() method is the key mechanism for this.

Step 1: Create Todo Form Component

First, let’s create a reusable form component. Create src/components/TodoForm.tsx:

import { useState } from 'react'
import type { CreateTodoData, UpdateTodoData, Todo } from "../lib/types";

interface TodoFormProps {
  todo?: Todo
  onSubmit: (data: CreateTodoData | UpdateTodoData) => Promise<void>
  onCancel: () => void
  isLoading?: boolean
}

export function TodoForm({ todo, onSubmit, onCancel, isLoading }: TodoFormProps) {
  const [title, setTitle] = useState(todo?.title || '')
  const [description, setDescription] = useState(todo?.description || '')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!title.trim()) return

    await onSubmit({
      title: title.trim(),
      description: description.trim(),
    })
  }

  return (
    <form onSubmit={handleSubmit} className="bg-white border rounded-lg p-6 shadow-sm">
      <div className="mb-4">
        <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
          Title *
        </label>
        <input
          type="text"
          id="title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          placeholder="Enter todo title..."
          required
          disabled={isLoading}
        />
      </div>

      <div className="mb-6">
        <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
          Description
        </label>
        <textarea
          id="description"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          rows={4}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          placeholder="Enter todo description..."
          disabled={isLoading}
        />
      </div>

      <div className="flex gap-3">
        <button
          type="submit"
          disabled={isLoading || !title.trim()}
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {isLoading ? 'Saving...' : todo ? 'Update Todo' : 'Create Todo'}
        </button>
        <button
          type="button"
          onClick={onCancel}
          disabled={isLoading}
          className="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400 disabled:opacity-50"
        >
          Cancel
        </button>
      </div>
    </form>
  )
}

Step 2: Create New Todo Route

Create src/routes/todos/new.tsx:

import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { TodoForm } from '../../components/TodoForm'
import { api } from '../../lib/api'
import type { CreateTodoData } from "../../lib/types";

function NewTodo() {
  const navigate = useNavigate()
  const router = useRouter()
  const [isLoading, setIsLoading] = useState(false)

  const handleSubmit = async (data: CreateTodoData) => {
    setIsLoading(true)
    try {
      const newTodo = await api.createTodo(data)

      // Invalidate todos list to refresh the cache
      await router.invalidate()

      // Navigate to the todos list
      navigate({ to: '/todos' })
    } catch (error) {
      console.error('Failed to create todo:', error)
      alert('Failed to create todo. Please try again.')
    } finally {
      setIsLoading(false)
    }
  }

  const handleCancel = () => {
    navigate({ to: '/todos' })
  }

  return (
    <div>
      <div className="mb-6">
        <h3 className="text-lg font-medium">Create New Todo</h3>
      </div>

      <TodoForm
        onSubmit={handleSubmit}
        onCancel={handleCancel}
        isLoading={isLoading}
      />
    </div>
  )
}

export const Route = createFileRoute('/todos/new')({
  component: NewTodo,
})

Step 3: Create Edit Todo Route

Create src/routes/todos/$todoId.edit.tsx:

import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { TodoForm } from '../../components/TodoForm'
import { api } from '../../lib/api'
import type { UpdateTodoData } from "../../lib/types";

function EditTodo() {
  const navigate = useNavigate()
  const router = useRouter()
  const todo = Route.useLoaderData()
  const { todoId } = Route.useParams()
  const [isLoading, setIsLoading] = useState(false)

  const handleSubmit = async (data: UpdateTodoData) => {
    setIsLoading(true)
    try {
      await api.updateTodo(Number(todoId), data)

      // Invalidate all cached data to refresh
      await router.invalidate()

      // Navigate back to todo detail
      navigate({ to: '/todos/$todoId', params: { todoId } })
    } catch (error) {
      console.error('Failed to update todo:', error)
      alert('Failed to update todo. Please try again.')
    } finally {
      setIsLoading(false)
    }
  }

  const handleCancel = () => {
    navigate({ to: '/todos/$todoId', params: { todoId } })
  }

  if (!todo) {
    return (
      <div className="text-center py-8">
        <h3 className="text-lg font-medium text-red-600">Todo not found</h3>
      </div>
    )
  }

  return (
    <div>
      <div className="mb-6">
        <h3 className="text-lg font-medium">Edit Todo</h3>
      </div>

      <TodoForm
        todo={todo}
        onSubmit={handleSubmit}
        onCancel={handleCancel}
        isLoading={isLoading}
      />
    </div>
  )
}

export const Route = createFileRoute('/todos/$todoId/edit')({
  loader: ({ params }) => api.getTodo(Number(params.todoId)),
  component: EditTodo,
})

Step 4: Enhanced Todo Detail with Mutations

Update src/routes/todos/$todoId.tsx to include better mutation handling:

import { createFileRoute, Link, useNavigate, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { api } from '../../lib/api'

function TodoDetail() {
  const navigate = useNavigate()
  const router = useRouter()
  const todo = Route.useLoaderData()
  const { todoId } = Route.useParams()
  const [isUpdating, setIsUpdating] = useState(false)

  const handleDelete = async () => {
    if (!confirm('Are you sure you want to delete this todo?')) return

    setIsUpdating(true)
    try {
      await api.deleteTodo(Number(todoId))

      // Invalidate cache and navigate to todos list
      await router.invalidate()
      navigate({ to: '/todos' })
    } catch (error) {
      console.error('Failed to delete todo:', error)
      alert('Failed to delete todo. Please try again.')
    } finally {
      setIsUpdating(false)
    }
  }

  const handleToggleComplete = async () => {
    setIsUpdating(true)
    try {
      await api.updateTodo(Number(todoId), { completed: !todo.completed })

      // Invalidate current route to reload data
      await router.invalidate()
    } catch (error) {
      console.error('Failed to update todo:', error)
      alert('Failed to update todo. Please try again.')
    } finally {
      setIsUpdating(false)
    }
  }

  if (!todo) {
    return (
      <div className="text-center py-8">
        <h3 className="text-lg font-medium text-gray-900">Todo not found</h3>
        <Link to="/todos" className="text-blue-600 hover:underline">
          Back to todos
        </Link>
      </div>
    )
  }

  return (
    <div>
      <div className="mb-6">
        <Link to="/todos" className="text-blue-600 hover:underline">
          ← Back to todos
        </Link>
      </div>

      <div className="bg-white border rounded-lg p-6 shadow-sm">
        <div className="flex items-start justify-between mb-4">
          <h3 className={`text-xl font-semibold ${todo.completed ? 'line-through text-gray-500' : ''}`}>
            {todo.title}
          </h3>
          <span className={`px-3 py-1 text-sm rounded-full ${
            todo.completed
              ? 'bg-green-100 text-green-800'
              : 'bg-yellow-100 text-yellow-800'
          }`}>
            {todo.completed ? 'Completed' : 'Pending'}
          </span>
        </div>

        <p className="text-gray-700 mb-4">{todo.description}</p>

        <p className="text-sm text-gray-500 mb-6">
          Created: {new Date(todo.createdAt).toLocaleDateString()}
        </p>

        <div className="flex gap-3">
          <button
            onClick={handleToggleComplete}
            disabled={isUpdating}
            className={`px-4 py-2 rounded disabled:opacity-50 ${
              todo.completed
                ? 'bg-yellow-600 text-white hover:bg-yellow-700'
                : 'bg-green-600 text-white hover:bg-green-700'
            }`}
          >
            {isUpdating ? 'Updating...' : `Mark as ${todo.completed ? 'Pending' : 'Completed'}`}
          </button>

          <Link
            to="/todos/$todoId/edit"
            params={{ todoId: todoId }}
            className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
          >
            Edit
          </Link>

          <button
            onClick={handleDelete}
            disabled={isUpdating}
            className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 disabled:opacity-50"
          >
            {isUpdating ? 'Deleting...' : 'Delete'}
          </button>
        </div>
      </div>
    </div>
  )
}

// Error and loading components remain the same...
function TodoDetailPending() {
  return (
    <div className="animate-pulse">
      <div className="mb-6">
        <div className="h-4 bg-gray-200 rounded w-20"></div>
      </div>
      <div className="bg-white border rounded-lg p-6 shadow-sm">
        <div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
        <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
        <div className="h-4 bg-gray-200 rounded w-2/3 mb-4"></div>
        <div className="h-4 bg-gray-200 rounded w-32 mb-6"></div>
        <div className="flex gap-3">
          <div className="h-8 bg-gray-200 rounded w-20"></div>
          <div className="h-8 bg-gray-200 rounded w-16"></div>
          <div className="h-8 bg-gray-200 rounded w-16"></div>
        </div>
      </div>
    </div>
  )
}

function TodoDetailError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="text-center py-8">
      <h3 className="text-lg font-medium text-red-600 mb-2">Failed to load todo</h3>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <div className="space-x-3">
        <button
          onClick={reset}
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          Try Again
        </button>
        <Link to="/todos" className="text-blue-600 hover:underline">
          Back to todos
        </Link>
      </div>
    </div>
  )
}

export const Route = createFileRoute('/todos/$todoId')({
  loader: async ({ params }) => {
    const todo = await api.getTodo(Number(params.todoId))
    if (!todo) {
      throw new Error(`Todo with ID ${params.todoId} not found`)
    }
    return todo
  },

  pendingComponent: TodoDetailPending,
  errorComponent: TodoDetailError,
  pendingMs: 500,
  pendingMinMs: 200,
  staleTime: 30 * 1000,

  component: TodoDetail,
})

Key Mutation Patterns

  1. Router Invalidation: Use router.invalidate() to refresh cached data after mutations
  2. Loading States: Track mutation loading states in component state
  3. Error Handling: Provide user feedback for failed mutations
  4. Optimistic Updates: For better UX, you could update local state immediately and revert on failure
  5. Cache Coordination: The router ensures all affected routes refresh their data

This approach keeps TanStack Router’s built-in caching in sync with your application’s data mutations while maintaining optimal performance.
 

TanStack Router provides powerful navigation capabilities through the Link component and programmatic navigation hooks. Let’s explore different navigation patterns and enhance our Todo app with better navigation UX.

Understanding TanStack Router Navigation

Every navigation in TanStack Router is relative, meaning you’re always navigating from one route to another. This concept enables powerful type-safe navigation and auto-completion.

TanStack Router’s Link component provides type-safe navigation with automatic href generation. Let’s enhance our navigation throughout the app.

Update src/routes/__root.tsx with improved navigation:

import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'

const RootComponent = () => (
  <>
    <header className="bg-blue-600 text-white shadow-sm">
      <div className="max-w-6xl mx-auto px-4 py-4">
        <div className="flex items-center justify-between">
          <Link to="/" className="text-xl font-bold hover:text-blue-100">
            Todo App
          </Link>

          <nav className="flex gap-6">
            <Link
              to="/"
              className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
              activeOptions={{ exact: true }}
            >
              Home
            </Link>

            <Link
              to="/todos"
              className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
            >
              Todos
            </Link>

            <Link
              to="/about"
              className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
            >
              About
            </Link>
          </nav>
        </div>
      </div>
    </header>

    <main className="max-w-6xl mx-auto px-4 py-8">
      <Outlet />
    </main>

    <TanStackRouterDevtools />
  </>
)

export const Route = createRootRoute({
  component: RootComponent,
})

Step 2: Programmatic Navigation

Let’s create a custom hook for common navigation patterns. Create src/lib/navigation.ts:

import { useNavigate, useRouter } from '@tanstack/react-router'

export function useAppNavigation() {
  const navigate = useNavigate()
  const router = useRouter()

  return {
    // Navigate to todos list
    toTodosList: () => navigate({ to: '/todos' }),

    // Navigate to specific todo
    toTodo: (todoId: string | number) =>
      navigate({
        to: '/todos/$todoId',
        params: { todoId: todoId.toString() }
      }),

    // Navigate to edit todo
    toEditTodo: (todoId: string | number) =>
      navigate({
        to: '/todos/$todoId/edit',
        params: { todoId: todoId.toString() }
      }),

    // Navigate to create new todo
    toNewTodo: () => navigate({ to: '/todos/new' }),

    // Navigate back with fallback
    goBack: (fallbackTo: string = '/todos') => {
      if (window.history.length > 1) {
        window.history.back()
      } else {
        navigate({ to: fallbackTo })
      }
    },

    // Navigate with search parameters
    toTodosWithFilter: (filter: 'all' | 'completed' | 'pending', search?: string) =>
      navigate({
        to: '/todos',
        search: { filter, search: search || '' }
      }),

    // Refresh current route
    refresh: () => router.invalidate(),
  }
}

Step 3: Smart Navigation Components

Create a breadcrumb component for better navigation context. Create src/components/Breadcrumbs.tsx:

import { Link, useMatches } from '@tanstack/react-router'

export function Breadcrumbs() {
  const matches = useMatches()

  // Build breadcrumb items from route matches
  const breadcrumbs = matches
    .filter(match => match.pathname !== '/')
    .map(match => {
      let label = 'Unknown'
      let to = match.pathname

      if (match.pathname === '/todos') {
        label = 'Todos'
      } else if (match.pathname === '/about') {
        label = 'About'
      } else if (match.pathname.startsWith('/todos/') && match.pathname.endsWith('/new')) {
        label = 'New Todo'
      } else if (match.pathname.startsWith('/todos/') && match.pathname.endsWith('/edit')) {
        label = 'Edit Todo'
      } else if (match.pathname.startsWith('/todos/') && match.params?.todoId) {
        label = `Todo #${match.params.todoId}`
      }

      return { label, to }
    })

  if (breadcrumbs.length === 0) return null

  return (
    <nav className="mb-6">
      <ol className="flex items-center space-x-2 text-sm text-gray-500">
        <li>
          <Link to="/" className="hover:text-gray-700">
            Home
          </Link>
        </li>
        {breadcrumbs.map((breadcrumb, index) => (
          <li key={breadcrumb.to} className="flex items-center space-x-2">
            <span>/</span>
            {index === breadcrumbs.length - 1 ? (
              <span className="text-gray-900 font-medium">{breadcrumb.label}</span>
            ) : (
              <Link to={breadcrumb.to} className="hover:text-gray-700">
                {breadcrumb.label}
              </Link>
            )}
          </li>
        ))}
      </ol>
    </nav>
  )
}

Step 4: Enhanced Todo List with Navigation

Update src/routes/todos/index.tsx to use better navigation patterns:

import { createFileRoute, Link } from '@tanstack/react-router'
import { z } from 'zod'
import { zodValidator } from '@tanstack/zod-adapter'
import { api } from '../../lib/api'
import { useAppNavigation } from '../../lib/navigation'
import { Breadcrumbs } from "@/components/BreadCrumbs";

const todosSearchSchema = z.object({
  filter: z.enum(['all', 'completed', 'pending']).default('all'),
  search: z.string().default(''),
})

function TodosList() {
  const todos = Route.useLoaderData()
  const { filter, search } = Route.useSearch()
  const navigation = useAppNavigation()

  const handleQuickAction = async (todoId: number, action: 'toggle' | 'delete') => {
    if (action === 'toggle') {
      const todo = todos.find(t => t.id === todoId)
      if (todo) {
        await api.updateTodo(todoId, { completed: !todo.completed })
        navigation.refresh()
      }
    } else if (action === 'delete') {
      if (confirm('Are you sure you want to delete this todo?')) {
        await api.deleteTodo(todoId)
        navigation.refresh()
      }
    }
  }

  return (
    <div>
      <Breadcrumbs />

      <div className="flex justify-between items-center mb-6">
        <h3 className="text-lg font-medium">
          Your Todos ({todos.length})
          {filter !== 'all' && (
            <span className="ml-2 text-sm text-gray-500">
              ({filter})
            </span>
          )}
        </h3>

        <button
          onClick={() => navigation.toNewTodo()}
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
        >
          Add New Todo
        </button>
      </div>

      {/* Enhanced Filter Controls */}
      <div className="mb-6 p-4 bg-gray-50 rounded-lg">
        <div className="flex gap-4 items-center flex-wrap">
          <div>
            <label className="text-sm font-medium text-gray-700">Filter:</label>
            <div className="flex gap-2 mt-1">
              {(['all', 'completed', 'pending'] as const).map(filterOption => (
                <button
                  key={filterOption}
                  onClick={() => navigation.toTodosWithFilter(filterOption, search)}
                  className={`px-3 py-1 text-sm rounded transition-colors ${
                    filter === filterOption
                      ? 'bg-blue-600 text-white'
                      : 'bg-white border hover:bg-gray-50'
                  }`}
                >
                  {filterOption.charAt(0).toUpperCase() + filterOption.slice(1)}
                </button>
              ))}
            </div>
          </div>

          <div className="flex-1 min-w-64">
            <label className="text-sm font-medium text-gray-700">Search:</label>
            <input
              type="text"
              value={search}
              onChange={(e) => navigation.toTodosWithFilter(filter, e.target.value)}
              placeholder="Search todos..."
              className="mt-1 block w-full px-3 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
            />
          </div>

          {(filter !== 'all' || search) && (
            <button
              onClick={() => navigation.toTodosWithFilter('all', '')}
              className="text-sm text-gray-600 hover:text-gray-800"
            >
              Clear filters
            </button>
          )}
        </div>
      </div>

      {todos.length === 0 ? (
        <div className="text-center py-12">
          <p className="text-gray-500 mb-4">
            {filter !== 'all' || search
              ? 'No todos match your criteria.'
              : 'No todos yet. Create your first one!'
            }
          </p>
          {filter === 'all' && !search && (
            <button
              onClick={() => navigation.toNewTodo()}
              className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
            >
              Create First Todo
            </button>
          )}
        </div>
      ) : (
        <div className="space-y-4">
          {todos.map(todo => (
            <div
              key={todo.id}
              className="border p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow"
            >
              <div className="flex items-start justify-between">
                <div className="flex-1">
                  <h4 className={`font-medium ${todo.completed ? 'line-through text-gray-500' : ''}`}>
                    {todo.title}
                  </h4>
                  <p className="text-gray-600 text-sm mt-1 line-clamp-2">{todo.description}</p>
                  <div className="flex items-center gap-3 mt-2">
                    <span className={`inline-block px-2 py-1 text-xs rounded ${
                      todo.completed
                        ? 'bg-green-100 text-green-800'
                        : 'bg-yellow-100 text-yellow-800'
                    }`}>
                      {todo.completed ? 'Completed' : 'Pending'}
                    </span>
                    <span className="text-xs text-gray-500">
                      Created {new Date(todo.createdAt).toLocaleDateString()}
                    </span>
                  </div>
                </div>

                <div className="flex flex-col gap-2 ml-4">
                  <div className="flex gap-2">
                    <Link
                      to="/todos/$todoId"
                      params={{ todoId: todo.id.toString() }}
                      className="text-blue-600 hover:underline text-sm"
                    >
                      View
                    </Link>
                    <Link
                      to="/todos/$todoId/edit"
                      params={{ todoId: todo.id.toString() }}
                      className="text-green-600 hover:underline text-sm"
                    >
                      Edit
                    </Link>
                  </div>

                  <div className="flex gap-2">
                    <button
                      onClick={() => handleQuickAction(todo.id, 'toggle')}
                      className="text-xs text-purple-600 hover:underline"
                    >
                      {todo.completed ? 'Mark Pending' : 'Mark Done'}
                    </button>
                    <button
                      onClick={() => handleQuickAction(todo.id, 'delete')}
                      className="text-xs text-red-600 hover:underline"
                    >
                      Delete
                    </button>
                  </div>
                </div>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

export const Route = createFileRoute('/todos/')({
  validateSearch: zodValidator(todosSearchSchema),
  loaderDeps: ({ search: { filter, search } }) => ({ filter, search }),
  loader: async ({ deps: { filter, search } }) => {
    let todos = await api.getTodos()

    if (filter !== 'all') {
      todos = todos.filter(todo =>
        filter === 'completed' ? todo.completed : !todo.completed
      )
    }

    if (search) {
      const searchLower = search.toLowerCase()
      todos = todos.filter(todo =>
        todo.title.toLowerCase().includes(searchLower) ||
        todo.description.toLowerCase().includes(searchLower)
      )
    }

    return todos
  },
  staleTime: 2 * 60 * 1000,
  component: TodosList,
})
  1. Type Safety: TanStack Router provides full TypeScript support for navigation parameters
  2. Relative Navigation: Always consider the from and to relationship for predictable navigation
  3. Search Parameters: Use type-safe search parameter handling for filters and state
  4. Programmatic Navigation: Combine useNavigate with custom hooks for reusable navigation logic
  5. User Experience: Provide clear navigation paths and breadcrumbs for complex applications

This comprehensive navigation setup provides users with intuitive ways to move through your application while maintaining type safety and optimal performance.
 

Error Handling

TanStack Router provides comprehensive error handling capabilities, including custom error components, error boundaries, and 404 page handling. Let’s implement robust error handling for our Todo app.

Understanding TanStack Router Error Handling

TanStack Router has built-in error boundaries that catch errors during:

  • Route loading (loader functions)
  • Component rendering
  • Navigation processes

The router provides several levels of error handling:

  1. Route-level error components - Handle errors for specific routes
  2. Default error component - Global fallback for unhandled errors
  3. Not found handling - Custom 404 pages
  4. Error recovery - Mechanisms to retry failed operations

Step 1: Create Custom Error Components

Create src/components/ErrorComponents.tsx:

import { Link, useRouter } from '@tanstack/react-router'
import { ErrorComponent } from '@tanstack/react-router'

interface CustomErrorProps {
  error: Error
  reset?: () => void
  info?: {
    componentStack: string
  }
}

export function TodoErrorFallback({ error, reset, info }: CustomErrorProps) {
  const router = useRouter()

  const handleRetry = async () => {
    if (reset) {
      reset()
    } else {
      // Fallback to router invalidation
      await router.invalidate()
    }
  }

  return (
    <div className="min-h-64 flex items-center justify-center">
      <div className="max-w-md mx-auto text-center">
        <div className="mb-6">
          <div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
            <svg className="w-8 h-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
            </svg>
          </div>
          <h3 className="text-lg font-medium text-gray-900 mb-2">
            Oops! Something went wrong
          </h3>
          <p className="text-gray-600 mb-4">
            {error.message || 'An unexpected error occurred while loading this page.'}
          </p>
        </div>

        <div className="space-y-3">
          <div className="flex gap-3 justify-center">
            <button
              onClick={handleRetry}
              className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
            >
              Try Again
            </button>
            <Link
              to="/todos"
              className="bg-gray-100 text-gray-700 px-4 py-2 rounded hover:bg-gray-200 transition-colors"
            >
              Go to Todos
            </Link>
            <Link
              to="/"
              className="text-blue-600 hover:underline px-4 py-2"
            >
              Go Home
            </Link>
          </div>

          {process.env.NODE_ENV === 'development' && (
            <details className="mt-6 text-left">
              <summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
                Error Details (Development)
              </summary>
              <div className="mt-2 p-3 bg-gray-50 rounded text-xs font-mono text-gray-800">
                <div className="mb-2">
                  <strong>Error:</strong> {error.message}
                </div>
                <div className="mb-2">
                  <strong>Stack:</strong>
                  <pre className="mt-1 whitespace-pre-wrap">{error.stack}</pre>
                </div>
                {info?.componentStack && (
                  <div>
                    <strong>Component Stack:</strong>
                    <pre className="mt-1 whitespace-pre-wrap">{info.componentStack}</pre>
                  </div>
                )}
              </div>
            </details>
          )}
        </div>
      </div>
    </div>
  )
}

export function NotFoundComponent() {
  return (
    <div className="min-h-64 flex items-center justify-center">
      <div className="max-w-md mx-auto text-center">
        <div className="mb-6">
          <div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
            <svg className="w-8 h-8 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 15c-2.87 0-5.431 1.5-6.873 3.797M3 12a9 9 0 1118 0 9 9 0 01-18 0z" />
            </svg>
          </div>
          <h3 className="text-lg font-medium text-gray-900 mb-2">
            Page Not Found
          </h3>
          <p className="text-gray-600 mb-4">
            The page you're looking for doesn't exist or has been moved.
          </p>
        </div>

        <div className="flex gap-3 justify-center">
          <Link
            to="/"
            className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
          >
            Go Home
          </Link>
          <Link
            to="/todos"
            className="bg-gray-100 text-gray-700 px-4 py-2 rounded hover:bg-gray-200 transition-colors"
          >
            Browse Todos
          </Link>
        </div>
      </div>
    </div>
  )
}

export function LoadingError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="text-center py-8">
      <h3 className="text-lg font-medium text-red-600 mb-2">Failed to load data</h3>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <div className="space-x-3">
        <button
          onClick={reset}
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          Try Again
        </button>
        <Link to="/todos" className="text-blue-600 hover:underline">
          Back to todos
        </Link>
      </div>
    </div>
  )
}

Step 2: Add Global Error Handling

Update src/routes/__root.tsx to include global error handling:

import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { TodoErrorFallback, NotFoundComponent } from '../components/ErrorComponents'

const RootComponent = () => (
  <>
    <header className="bg-blue-600 text-white shadow-sm">
      <div className="max-w-6xl mx-auto px-4 py-4">
        <div className="flex items-center justify-between">
          <Link to="/" className="text-xl font-bold hover:text-blue-100">
            Todo App
          </Link>

          <nav className="flex gap-6">
            <Link
              to="/"
              className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
              activeOptions={{ exact: true }}
            >
              Home
            </Link>

            <Link
              to="/todos"
              className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
            >
              Todos
            </Link>

            <Link
              to="/about"
              className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
            >
              About
            </Link>
          </nav>
        </div>
      </div>
    </header>

    <main className="max-w-6xl mx-auto px-4 py-8 min-h-screen">
      <Outlet />
    </main>

    <TanStackRouterDevtools />
  </>
)

export const Route = createRootRoute({
  component: RootComponent,
  // Global error component for unhandled errors
  errorComponent: TodoErrorFallback,
  // Global not found component
  notFoundComponent: NotFoundComponent,
})

Step 3: Route-Specific Error Handling

Update src/routes/todos/$todoId.tsx with enhanced error handling:

import { createFileRoute, Link, useNavigate, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { api } from '../../lib/api'
import { LoadingError } from '../../components/ErrorComponents'
import { Breadcrumbs } from '../../components/BreadCrumbs'

function TodoDetail() {
  const navigate = useNavigate()
  const router = useRouter()
  const todo = Route.useLoaderData()
  const { todoId } = Route.useParams()
  const [isUpdating, setIsUpdating] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const handleDelete = async () => {
    if (!confirm('Are you sure you want to delete this todo?')) return

    setIsUpdating(true)
    setError(null)

    try {
      const success = await api.deleteTodo(Number(todoId))
      if (!success) {
        throw new Error('Failed to delete todo')
      }

      await router.invalidate()
      navigate({ to: '/todos' })
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to delete todo')
    } finally {
      setIsUpdating(false)
    }
  }

  const handleToggleComplete = async () => {
    setIsUpdating(true)
    setError(null)

    try {
      const updated = await api.updateTodo(Number(todoId), { completed: !todo.completed })
      if (!updated) {
        throw new Error('Failed to update todo')
      }

      await router.invalidate()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to update todo')
    } finally {
      setIsUpdating(false)
    }
  }

  return (
    <div>
      <Breadcrumbs />

      {error && (
        <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
          <div className="flex items-center">
            <svg className="w-5 h-5 text-red-600 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
            </svg>
            <span className="text-red-800">{error}</span>
            <button
              onClick={() => setError(null)}
              className="ml-auto text-red-600 hover:text-red-800"
            >
              ×
            </button>
          </div>
        </div>
      )}

      <div className="bg-white border rounded-lg p-6 shadow-sm">
        <div className="flex items-start justify-between mb-4">
          <h3 className={`text-xl font-semibold ${todo.completed ? 'line-through text-gray-500' : ''}`}>
            {todo.title}
          </h3>
          <span className={`px-3 py-1 text-sm rounded-full ${
            todo.completed
              ? 'bg-green-100 text-green-800'
              : 'bg-yellow-100 text-yellow-800'
          }`}>
            {todo.completed ? 'Completed' : 'Pending'}
          </span>
        </div>

        <p className="text-gray-700 mb-4">{todo.description}</p>

        <p className="text-sm text-gray-500 mb-6">
          Created: {new Date(todo.createdAt).toLocaleDateString()}
        </p>

        <div className="flex gap-3">
          <button
            onClick={handleToggleComplete}
            disabled={isUpdating}
            className={`px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
              todo.completed
                ? 'bg-yellow-600 text-white hover:bg-yellow-700'
                : 'bg-green-600 text-white hover:bg-green-700'
            }`}
          >
            {isUpdating ? 'Updating...' : `Mark as ${todo.completed ? 'Pending' : 'Completed'}`}
          </button>

          <Link
            to="/todos/$todoId/edit"
            params={{ todoId: todoId }}
            className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
          >
            Edit
          </Link>

          <button
            onClick={handleDelete}
            disabled={isUpdating}
            className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
          >
            {isUpdating ? 'Deleting...' : 'Delete'}
          </button>
        </div>
      </div>
    </div>
  )
}

function TodoDetailPending() {
  return (
    <div>
      <div className="mb-6">
        <div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
      </div>
      <div className="bg-white border rounded-lg p-6 shadow-sm">
        <div className="animate-pulse">
          <div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
          <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
          <div className="h-4 bg-gray-200 rounded w-2/3 mb-4"></div>
          <div className="h-4 bg-gray-200 rounded w-32 mb-6"></div>
          <div className="flex gap-3">
            <div className="h-8 bg-gray-200 rounded w-20"></div>
            <div className="h-8 bg-gray-200 rounded w-16"></div>
            <div className="h-8 bg-gray-200 rounded w-16"></div>
          </div>
        </div>
      </div>
    </div>
  )
}

export const Route = createFileRoute('/todos/$todoId')({
  loader: async ({ params }) => {
    try {
      const todo = await api.getTodo(Number(params.todoId))
      if (!todo) {
        throw new Error(`Todo with ID ${params.todoId} not found`)
      }
      return todo
    } catch (error) {
      // Add context to the error
      if (error instanceof Error) {
        throw new Error(`Failed to load todo: ${error.message}`)
      }
      throw new Error(`Failed to load todo with ID ${params.todoId}`)
    }
  },

  // Custom error handling for this route
  errorComponent: LoadingError,

  // Loading component
  pendingComponent: TodoDetailPending,

  // Loading timing configuration
  pendingMs: 500,        // Show loading after 500ms
  pendingMinMs: 200,     // Show loading for at least 200ms

  // Cache configuration
  staleTime: 30 * 1000,  // Consider fresh for 30 seconds

  // Error handling during route loading
  onError: ({ error }) => {
    console.error('Todo detail route error:', error)
    // Could integrate with error tracking service here
  },

  component: TodoDetail,
})

Step 4: 404 Error Handling

Create a custom 404 route. Create src/routes/404.tsx:

import { createFileRoute } from '@tanstack/react-router'
import { NotFoundComponent } from '../components/ErrorComponents'

export const Route = createFileRoute('/404')({
  component: NotFoundComponent,
})

Step 5: Error Boundary Testing

Add a test route to verify error handling. Create src/routes/error-test.tsx:

import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'

function ErrorTest() {
  const [shouldError, setShouldError] = useState(false)

  if (shouldError) {
    throw new Error('This is a test error to demonstrate error boundaries!')
  }

  return (
    <div className="max-w-md mx-auto p-6">
      <h2 className="text-xl font-semibold mb-4">Error Boundary Testing</h2>
      <p className="text-gray-600 mb-6">
        This page helps test the error handling capabilities of our Todo app.
      </p>

      <div className="space-y-4">
        <button
          onClick={() => setShouldError(true)}
          className="w-full bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
        >
          Trigger Component Error
        </button>

        <button
          onClick={() => {
            // Trigger a loader error by navigating to non-existent todo
            window.location.href = '/todos/99999'
          }}
          className="w-full bg-yellow-600 text-white px-4 py-2 rounded hover:bg-yellow-700"
        >
          Trigger Loader Error
        </button>

        <button
          onClick={() => {
            window.location.href = '/non-existent-route'
          }}
          className="w-full bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700"
        >
          Trigger 404 Error
        </button>
      </div>
    </div>
  )
}

export const Route = createFileRoute('/error-test')({
  component: ErrorTest,
})

Error Handling Best Practices

  1. Graceful Degradation: Always provide fallback UI and recovery options
  2. User-Friendly Messages: Convert technical errors into understandable messages
  3. Error Tracking: Log errors for debugging while protecting user privacy
  4. Retry Mechanisms: Allow users to retry failed operations
  5. Context Preservation: Maintain navigation context even during errors
  6. Development vs Production: Show detailed errors in development, sanitized messages in production

This comprehensive error handling setup ensures your Todo app remains resilient and provides excellent user experience even when things go wrong.
 

Conclusion

Congratulations! You’ve successfully built a comprehensive Todo application using TanStack Router that demonstrates all the major features of this powerful routing library. Let’s recap what we’ve accomplished and explore next steps for further learning.

What We Built

Our Todo application showcases the full spectrum of TanStack Router capabilities:

Modern Routing Architecture: We implemented file-based routing with a clean, intuitive structure that maps directly to our application’s URL hierarchy.

Type-Safe Navigation: Every navigation action is fully typed, preventing routing errors at compile time and providing excellent developer experience with autocomplete and IntelliSense.

Advanced Data Loading: We utilized TanStack Router’s built-in loader system with SWR caching, search parameter validation, and intelligent preloading to create a fast, responsive user experience.

Robust Error Handling: Our application gracefully handles errors at multiple levels - from individual route failures to global error boundaries - ensuring users never encounter broken states.

Seamless State Management: By leveraging the router’s invalidation system, we kept cached data in perfect sync with mutations, eliminating the complexity typically associated with state management.

Key Features Demonstrated

  1. File-Based Routing with nested routes and layouts
  2. Type-Safe Parameters for route parameters and search queries
  3. Data Loading with caching, dependencies, and error handling
  4. Mutation Coordination with cache invalidation
  5. Programmatic Navigation with custom hooks and utilities
  6. Error Boundaries with recovery mechanisms
  7. Loading States with customizable pending components
  8. Search Parameter Management with validation and type safety

Performance Benefits Realized

  • Zero Loading Flickers: Data loads before components render
  • Intelligent Caching: Automatic SWR caching reduces redundant requests
  • Code Splitting: Automatic route-based code splitting for optimal bundle sizes
  • Preloading: Smart preloading based on user interaction patterns
  • Structural Sharing: Minimized re-renders through intelligent state sharing

Next Steps for Learning

Explore Advanced Features:

  • TanStack Start: Full-stack framework built on TanStack Router for SSR and API routes
  • Route Context: Advanced patterns for sharing data between parent and child routes
  • Virtual File Routes: Custom route generation for complex requirements
  • Route Masking: Advanced URL rewriting capabilities

Integration Opportunities:

  • TanStack Query: For more sophisticated server state management
  • TanStack Form: Type-safe form handling with validation
  • TanStack Table: Data grid components with routing integration
  • Authentication: Implement protected routes and user sessions

Production Considerations:

  • Error Monitoring: Integrate with services like Sentry for production error tracking
  • Performance Monitoring: Add analytics and performance measurement
  • SEO Optimization: Implement meta tags and structured data
  • Accessibility: Enhance keyboard navigation and screen reader support

Community and Resources

Official Resources:

Learning Materials:

Why Choose TanStack Router?

After building this Todo application, you’ve experienced firsthand why TanStack Router represents the future of React routing:

  • Developer Experience: Type safety eliminates entire categories of bugs
  • Performance: Built-in optimizations provide excellent user experience
  • Modern Architecture: Designed for contemporary React development patterns
  • Comprehensive Features: Everything you need in a single, cohesive package
  • Active Development: Continuous innovation from the TanStack team

TanStack Router excels particularly well for:

  • TypeScript-first applications where compile-time safety is crucial
  • Complex applications with sophisticated data loading requirements
  • Modern development teams comfortable with file-based routing patterns
  • Performance-sensitive applications where every millisecond matters

 

Final Thoughts

TanStack Router represents a significant evolution in React routing, combining the best ideas from frameworks like Next.js and Remix while maintaining the flexibility and control that React developers love. The type safety, performance optimizations, and developer experience improvements make it an excellent choice for modern React applications.

Your Todo application serves as a solid foundation for building more complex applications. The patterns and techniques you’ve learned - from route organization to error handling to state management - will scale effectively as your applications grow in complexity.

Download the source code here: https://github.com/rtome85/todo-app-tanstack

Tags:

React TanStack Router TypeScript Type-Safe Routing Tutorial Todo App

Share this post:

Complete TanStack Router Tutorial: Building a Todo App