TanStack Router Testing Guide: Routes, Loaders, and Guards with Vitest

TanStack Router Testing Guide: Routes, Loaders, and Guards with Vitest

TanStack Router is a fully type-safe router for React with built-in route loaders, search parameter validation, and nested layouts. Its type-first design means bugs often show up at build time — but runtime behaviour like data loading, access guards, and navigation still needs tests.

This guide covers unit and integration testing for TanStack Router using Vitest and React Testing Library.

Setup

npm install @tanstack/react-router
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

vitest.config.ts:

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
  },
})

The TanStack Router Mental Model

TanStack Router routes are objects created with createRoute. Each route has:

  • A component — what renders at that path
  • A loader — async data fetching before render
  • A beforeLoad — guard for auth checks
  • validateSearch — typed search parameter parsing

Testing each piece separately keeps tests focused and fast.

Testing Route Components

Route components are regular React components. Test them in isolation by providing the context they need:

// routes/dashboard/index.tsx
import { createFileRoute } from '@tanstack/react-router'

interface DashboardData {
  totalUsers: number
  activeTests: number
  lastRunAt: string
}

export const Route = createFileRoute('/dashboard/')({
  loader: async () => {
    const res = await fetch('/api/dashboard')
    return res.json() as Promise<DashboardData>
  },
  component: DashboardPage,
})

function DashboardPage() {
  const data = Route.useLoaderData()
  return (
    <main>
      <h1>Dashboard</h1>
      <dl>
        <dt>Total users</dt>
        <dd>{data.totalUsers}</dd>
        <dt>Active tests</dt>
        <dd>{data.activeTests}</dd>
      </dl>
    </main>
  )
}

To test DashboardPage in isolation, mock the useLoaderData hook:

// routes/dashboard/index.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'

vi.mock('@tanstack/react-router', async () => {
  const actual = await vi.importActual('@tanstack/react-router')
  return {
    ...actual,
    // We'll override useLoaderData per test
  }
})

// A simpler approach: test the component by extracting it

function DashboardPage({ data }: { data: { totalUsers: number; activeTests: number } }) {
  return (
    <main>
      <h1>Dashboard</h1>
      <dl>
        <dt>Total users</dt>
        <dd>{data.totalUsers}</dd>
        <dt>Active tests</dt>
        <dd>{data.activeTests}</dd>
      </dl>
    </main>
  )
}

describe('DashboardPage', () => {
  it('renders dashboard metrics', () => {
    render(<DashboardPage data={{ totalUsers: 42, activeTests: 7 }} />)

    expect(screen.getByText('42')).toBeInTheDocument()
    expect(screen.getByText('7')).toBeInTheDocument()
  })
})

Testing Route Loaders

Loaders are async functions — test them directly:

// routes/users/$userId/loader.ts
import { db } from '@/lib/db'

export async function userLoader({ params }: { params: { userId: string } }) {
  const user = await db.users.findUnique({
    where: { id: params.userId },
    select: { id: true, name: true, email: true, role: true },
  })

  if (!user) {
    throw new Response('Not Found', { status: 404 })
  }

  return user
}
// routes/users/$userId/loader.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { userLoader } from './loader'

vi.mock('@/lib/db', () => ({
  db: {
    users: {
      findUnique: vi.fn(),
    },
  },
}))

import { db } from '@/lib/db'

describe('userLoader', () => {
  beforeEach(() => vi.clearAllMocks())

  it('returns user data when found', async () => {
    vi.mocked(db.users.findUnique).mockResolvedValue({
      id: 'user-1',
      name: 'Alice Chen',
      email: 'alice@example.com',
      role: 'admin',
    })

    const result = await userLoader({ params: { userId: 'user-1' } })

    expect(result.name).toBe('Alice Chen')
    expect(result.role).toBe('admin')
  })

  it('throws 404 Response when user not found', async () => {
    vi.mocked(db.users.findUnique).mockResolvedValue(null)

    await expect(
      userLoader({ params: { userId: 'nonexistent' } })
    ).rejects.toThrow()

    try {
      await userLoader({ params: { userId: 'nonexistent' } })
    } catch (e) {
      expect(e instanceof Response).toBe(true)
      expect((e as Response).status).toBe(404)
    }
  })
})

Testing Route Guards (beforeLoad)

beforeLoad runs before the loader and before the component mounts. Use it for auth checks:

// routes/admin/route.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getSession } from '@/lib/auth'

export const Route = createFileRoute('/admin')({
  beforeLoad: async () => {
    const session = await getSession()

    if (!session) {
      throw redirect({ to: '/login', search: { returnTo: '/admin' } })
    }

    if (session.role !== 'admin') {
      throw redirect({ to: '/dashboard' })
    }

    return { session }
  },
  component: AdminPage,
})

Test the guard logic as a standalone function:

// routes/admin/guard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { redirect } from '@tanstack/react-router'

vi.mock('@/lib/auth', () => ({
  getSession: vi.fn(),
}))
vi.mock('@tanstack/react-router', async () => {
  const actual = await vi.importActual('@tanstack/react-router')
  return {
    ...actual,
    redirect: vi.fn((options) => {
      const error = new Error('Redirect')
      Object.assign(error, options)
      return error
    }),
  }
})

import { getSession } from '@/lib/auth'

// Extract guard logic for direct testing
async function adminGuard() {
  const session = await getSession()

  if (!session) {
    throw redirect({ to: '/login', search: { returnTo: '/admin' } })
  }

  if (session.role !== 'admin') {
    throw redirect({ to: '/dashboard' })
  }

  return { session }
}

describe('Admin route guard', () => {
  it('allows access for admin users', async () => {
    vi.mocked(getSession).mockResolvedValue({ userId: '1', role: 'admin' })

    const result = await adminGuard()

    expect(result.session.role).toBe('admin')
  })

  it('redirects unauthenticated users to login', async () => {
    vi.mocked(getSession).mockResolvedValue(null)

    await expect(adminGuard()).rejects.toMatchObject({
      to: '/login',
      search: { returnTo: '/admin' },
    })
  })

  it('redirects non-admin users to dashboard', async () => {
    vi.mocked(getSession).mockResolvedValue({ userId: '2', role: 'user' })

    await expect(adminGuard()).rejects.toMatchObject({
      to: '/dashboard',
    })
  })
})

Testing Search Parameters

TanStack Router validates search parameters with validateSearch. Test that valid and invalid params behave correctly:

// routes/search/route.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

const searchSchema = z.object({
  q: z.string().optional(),
  page: z.number().int().min(1).default(1),
  limit: z.number().int().min(1).max(100).default(20),
  sort: z.enum(['name', 'date', 'relevance']).default('relevance'),
})

export const Route = createFileRoute('/search')({
  validateSearch: (search) => searchSchema.parse(search),
  component: SearchPage,
})

Test the schema directly:

// routes/search/schema.test.ts
import { describe, it, expect } from 'vitest'
import { z } from 'zod'

const searchSchema = z.object({
  q: z.string().optional(),
  page: z.number().int().min(1).default(1),
  limit: z.number().int().min(1).max(100).default(20),
  sort: z.enum(['name', 'date', 'relevance']).default('relevance'),
})

describe('Search route schema', () => {
  it('applies defaults when params are omitted', () => {
    const result = searchSchema.parse({})
    expect(result.page).toBe(1)
    expect(result.limit).toBe(20)
    expect(result.sort).toBe('relevance')
  })

  it('parses valid search params', () => {
    const result = searchSchema.parse({ q: 'vitest', page: 2, sort: 'name' })
    expect(result.q).toBe('vitest')
    expect(result.page).toBe(2)
    expect(result.sort).toBe('name')
  })

  it('rejects invalid sort value', () => {
    expect(() => searchSchema.parse({ sort: 'invalid' })).toThrow()
  })

  it('rejects page below 1', () => {
    expect(() => searchSchema.parse({ page: 0 })).toThrow()
  })

  it('clamps limit to 100', () => {
    expect(() => searchSchema.parse({ limit: 101 })).toThrow()
  })
})

Integration Testing with createMemoryHistory

For integration tests that exercise navigation and routing together, use createMemoryHistory to avoid browser dependencies:

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createRouter, RouterProvider, createMemoryHistory } from '@tanstack/react-router'
import { describe, it, expect } from 'vitest'
import { routeTree } from './routeTree.gen'

function createTestRouter(initialPath = '/') {
  return createRouter({
    routeTree,
    history: createMemoryHistory({ initialEntries: [initialPath] }),
  })
}

describe('Dashboard navigation', () => {
  it('navigates from dashboard to user detail', async () => {
    const router = createTestRouter('/dashboard')

    render(<RouterProvider router={router} />)

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
    })

    await userEvent.click(screen.getByRole('link', { name: 'Alice Chen' }))

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Alice Chen' })).toBeInTheDocument()
    })
  })
})

What Automated Tests Miss

Unit tests cover route logic, but not:

  • Navigation timing under real network latency
  • Browser back/forward button behaviour
  • Search parameter serialization edge cases in real URLs
  • Multi-tab navigation state

HelpMeTest runs Playwright tests against your deployed app on a schedule — catching navigation regressions that only appear in a live browser environment.

Summary

TanStack Router's typed API makes the testing strategy clear:

  • Route components → test the UI logic in isolation with prop injection
  • Loaders → test as async functions with mocked data dependencies
  • Guards → extract and test as standalone functions; assert redirect targets
  • Search schemas → test Zod schemas directly for valid/invalid inputs
  • Integration → use createMemoryHistory for navigation flow tests

Read more