E2E Testing TanStack Router SPAs with Playwright: Navigation, Search Params, Nested Layouts
TanStack Router SPAs use client-side navigation — no full page reloads, history managed by the router, search params validated by Zod. Playwright handles all of this well, but you need to account for how the SPA manages state and URL.
This guide covers Playwright E2E testing for TanStack Router: client-side navigation, search parameter flows, nested layouts, and common SPA-specific pitfalls.
Playwright Setup for TanStack Router SPAs
npm install -D @playwright/test
npx playwright installplaywright.config.ts:
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})Testing Client-Side Navigation
TanStack Router navigates without full page reloads. Playwright's network assertions work differently in SPA mode — you're watching URL changes and DOM updates rather than navigation requests.
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Dashboard navigation', () => {
test('navigates from user list to user detail', async ({ page }) => {
await page.goto('/users')
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible()
// TanStack Router uses <Link> components which render as <a> tags
await page.getByRole('link', { name: 'Alice Chen' }).click()
// URL updates without page reload
await expect(page).toHaveURL(/\/users\/[a-z0-9-]+/)
await expect(page.getByRole('heading', { name: 'Alice Chen' })).toBeVisible()
})
test('back button returns to correct list position', async ({ page }) => {
await page.goto('/users')
// Scroll down and click a user
await page.getByRole('link', { name: 'Bob Lee' }).click()
await expect(page.getByRole('heading', { name: 'Bob Lee' })).toBeVisible()
await page.goBack()
// Should be back on the list, at the same scroll position
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Bob Lee' })).toBeVisible()
})
test('programmatic navigation via router navigate', async ({ page }) => {
await page.goto('/dashboard')
// Test a button that uses router.navigate() internally
await page.getByRole('button', { name: 'Go to settings' }).click()
await expect(page).toHaveURL('/settings')
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible()
})
})Testing Search Parameters
TanStack Router validates and serializes search params. E2E tests should verify the full round-trip: user interaction → URL param update → component renders correctly.
// e2e/search-params.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Product search with URL params', () => {
test('search query is reflected in URL', async ({ page }) => {
await page.goto('/products')
const searchInput = page.getByRole('searchbox', { name: 'Search products' })
await searchInput.fill('wireless headphones')
// Debounced — wait for URL to update
await expect(page).toHaveURL(/q=wireless\+headphones/, { timeout: 2000 })
})
test('pagination updates page param in URL', async ({ page }) => {
await page.goto('/products')
await expect(page).toHaveURL(/page=1|\/products$/)
await page.getByRole('button', { name: 'Next page' }).click()
await expect(page).toHaveURL(/page=2/)
await expect(page.getByText('Page 2 of')).toBeVisible()
})
test('sort selection updates sort param', async ({ page }) => {
await page.goto('/products')
await page.getByRole('combobox', { name: 'Sort by' }).selectOption('name')
await expect(page).toHaveURL(/sort=name/)
})
test('direct link with params renders correctly', async ({ page }) => {
// Deep link directly with search params
await page.goto('/products?q=headphones&sort=name&page=2')
const searchInput = page.getByRole('searchbox', { name: 'Search products' })
await expect(searchInput).toHaveValue('headphones')
const sortSelect = page.getByRole('combobox', { name: 'Sort by' })
await expect(sortSelect).toHaveValue('name')
await expect(page.getByText('Page 2 of')).toBeVisible()
})
test('invalid search params fall back to defaults', async ({ page }) => {
// TanStack Router validates with Zod — invalid params use defaults
await page.goto('/products?page=invalid&sort=nonexistent')
await expect(page.getByText('Page 1 of')).toBeVisible()
// Default sort should be applied
await expect(page.getByRole('combobox', { name: 'Sort by' })).toHaveValue('relevance')
})
})Testing Nested Layouts
TanStack Router's layout routes wrap child routes. E2E tests verify that layout elements persist across navigation within the same layout boundary.
// e2e/nested-layouts.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Dashboard nested layouts', () => {
test.beforeEach(async ({ page }) => {
// Assume auth is handled — go directly to authenticated route
await page.goto('/dashboard')
await expect(page.getByTestId('sidebar')).toBeVisible()
})
test('sidebar persists when navigating between dashboard pages', async ({ page }) => {
// Initial page
await expect(page.getByTestId('sidebar')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
// Navigate to another page in the same layout
await page.getByRole('link', { name: 'Analytics' }).click()
await expect(page).toHaveURL('/dashboard/analytics')
// Sidebar stays — no full page reload
await expect(page.getByTestId('sidebar')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible()
})
test('settings layout has its own sub-navigation', async ({ page }) => {
await page.getByRole('link', { name: 'Settings' }).click()
await expect(page).toHaveURL('/dashboard/settings')
// Both parent sidebar and settings sub-nav are visible
await expect(page.getByTestId('sidebar')).toBeVisible()
await expect(page.getByTestId('settings-nav')).toBeVisible()
})
test('settings sub-navigation persists within settings section', async ({ page }) => {
await page.goto('/dashboard/settings/profile')
await page.getByRole('link', { name: 'Billing' }).click()
await expect(page).toHaveURL('/dashboard/settings/billing')
await expect(page.getByTestId('sidebar')).toBeVisible()
await expect(page.getByTestId('settings-nav')).toBeVisible()
})
test('breadcrumb reflects current depth', async ({ page }) => {
await page.goto('/dashboard/settings/billing')
const breadcrumb = page.getByRole('navigation', { name: 'Breadcrumb' })
await expect(breadcrumb).toContainText('Dashboard')
await expect(breadcrumb).toContainText('Settings')
await expect(breadcrumb).toContainText('Billing')
})
})Testing Route Guards in E2E
Route guards (via beforeLoad) redirect unauthenticated users. Test that the redirect happens and the return URL is preserved:
// e2e/auth-guards.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Route guards', () => {
test('unauthenticated user is redirected to login', async ({ page }) => {
// Navigate directly to protected route without logging in
await page.goto('/dashboard')
// Should be redirected
await expect(page).toHaveURL(/\/login(\?|$)/)
})
test('login redirects preserve the return URL', async ({ page }) => {
await page.goto('/dashboard/settings')
// Check that returnTo is in the URL
await expect(page).toHaveURL(/returnTo=%2Fdashboard%2Fsettings/)
})
test('after login, user is sent to the original destination', async ({ page }) => {
await page.goto('/dashboard/settings')
// Fill login form
await page.getByLabel('Email').fill('alice@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
// Should end up at the original destination
await expect(page).toHaveURL('/dashboard/settings')
})
test('non-admin is redirected from admin routes', async ({ page }) => {
// Log in as non-admin user first
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('userpassword')
await page.getByRole('button', { name: 'Sign in' }).click()
await page.goto('/admin')
await expect(page).toHaveURL('/dashboard')
await expect(page.getByText(/access denied|not authorized/i)).not.toBeVisible()
})
})Testing Loading States
TanStack Router's loaders fetch data before rendering. The loading state appears during navigation:
// e2e/loading-states.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Route loading states', () => {
test('shows loading indicator while route data loads', async ({ page }) => {
// Slow down the API to capture the loading state
await page.route('**/api/users/**', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 300))
await route.continue()
})
await page.goto('/users')
// Click a user before the data is ready
const userLink = page.getByRole('link', { name: 'Alice Chen' }).first()
await userLink.click()
// Loading state should appear
await expect(page.getByRole('status', { name: /loading/i })).toBeVisible()
// Then the content loads
await expect(page.getByRole('heading', { name: 'Alice Chen' })).toBeVisible()
})
test('error state is shown when loader fails', async ({ page }) => {
await page.route('**/api/users/bad-id', (route) =>
route.fulfill({ status: 404, body: JSON.stringify({ error: 'Not found' }) })
)
await page.goto('/users/bad-id')
await expect(page.getByText(/not found|user does not exist/i)).toBeVisible()
})
})Handling SPA-specific Playwright Gotchas
Waiting for navigation to complete:
// Don't use waitForNavigation with client-side routing
// TanStack Router doesn't trigger full page loads
// Instead, wait for the URL to change
await page.getByRole('link', { name: 'Settings' }).click()
await page.waitForURL('**/settings')Checking that no page reload occurred:
test('navigation does not reload the page', async ({ page }) => {
await page.goto('/dashboard')
// Mark the page to detect reloads
await page.evaluate(() => {
window.__noReload = true
})
await page.getByRole('link', { name: 'Analytics' }).click()
await page.waitForURL('**/analytics')
// Marker should still be there — no reload happened
const marker = await page.evaluate(() => window.__noReload)
expect(marker).toBe(true)
})What Automated Tests Miss
Playwright E2E tests cover a lot but not:
- Navigation performance under slow device CPUs
- Memory leaks from uncleared route subscriptions
- Mobile browser quirks with history management
- Deep linking edge cases in production routing config
HelpMeTest runs scheduled Playwright tests against your deployed app. When a nav regression ships, continuous monitoring catches it within minutes.
Summary
Testing TanStack Router SPAs with Playwright:
- Client-side navigation → click links, assert URL changes and content renders without waiting for page reloads
- Search params → fill inputs, assert URL updates; deep-link directly and assert component state
- Nested layouts → assert that layout elements persist across navigation within the same segment
- Route guards → test redirect targets, return URL preservation, and role-based access
- Loading states → throttle API routes to capture the loading state before assertions
The key mindset shift from SSR testing: you're not waiting for page loads — you're watching URL and DOM changes driven by the router.