Next.js 15 App Router Testing: Layouts, Loading, Error, and Route Handlers
Next.js 15 App Router introduced a file-based convention that determines how your application behaves in loading, error, and layout states. Each file — layout.tsx, loading.tsx, error.tsx, not-found.tsx — plays a distinct role, and each one needs tests that match its purpose.
This guide covers how to test each App Router convention file, plus route handlers and nested layouts.
The App Router File Conventions
Before writing tests, map what each file does:
| File | When rendered | What to test |
|---|---|---|
layout.tsx |
Wraps every page in that segment | Renders children, persists across navigations |
loading.tsx |
While the page Promise is pending | Shows immediately, replaced when data arrives |
error.tsx |
When a page throws | reset function works, error message shown |
not-found.tsx |
When notFound() is called |
Renders 404 UI, links work |
Setting Up Tests
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-domvitest.config.ts:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
alias: {
'@': path.resolve(__dirname, './'),
},
},
})Testing layout.tsx
Layouts wrap their children and persist while the user navigates within that segment. A typical layout:
// app/dashboard/layout.tsx
import { Sidebar } from '@/components/Sidebar'
import { TopBar } from '@/components/TopBar'
interface Props {
children: React.ReactNode
}
export default function DashboardLayout({ children }: Props) {
return (
<div className="dashboard">
<TopBar />
<div className="dashboard__content">
<Sidebar />
<main>{children}</main>
</div>
</div>
)
}Test that it renders children and required chrome:
// app/dashboard/layout.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import DashboardLayout from './layout'
vi.mock('@/components/Sidebar', () => ({
Sidebar: () => <nav data-testid="sidebar">Sidebar</nav>,
}))
vi.mock('@/components/TopBar', () => ({
TopBar: () => <header data-testid="topbar">TopBar</header>,
}))
describe('DashboardLayout', () => {
it('renders children inside the main area', () => {
render(
<DashboardLayout>
<p>Page content</p>
</DashboardLayout>
)
expect(screen.getByText('Page content')).toBeInTheDocument()
expect(screen.getByTestId('sidebar')).toBeInTheDocument()
expect(screen.getByTestId('topbar')).toBeInTheDocument()
})
it('wraps content in main element', () => {
render(
<DashboardLayout>
<p>Content</p>
</DashboardLayout>
)
const main = screen.getByRole('main')
expect(main).toContainElement(screen.getByText('Content'))
})
})Testing loading.tsx
loading.tsx is shown while the page component's async data is loading. It renders inside the segment's Suspense boundary automatically.
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="loading-state" aria-label="Loading dashboard">
<div className="loading-state__spinner" aria-hidden="true" />
<p>Loading your dashboard…</p>
</div>
)
}Test it in isolation — it's a pure component:
// app/dashboard/loading.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import DashboardLoading from './loading'
describe('DashboardLoading', () => {
it('shows loading message', () => {
render(<DashboardLoading />)
expect(screen.getByText('Loading your dashboard…')).toBeInTheDocument()
})
it('has accessible loading label', () => {
render(<DashboardLoading />)
expect(screen.getByLabelText('Loading dashboard')).toBeInTheDocument()
})
})Also test the integration with its parent page using a Suspense wrapper:
import { render, screen } from '@testing-library/react'
import { Suspense } from 'react'
import DashboardLoading from './loading'
describe('DashboardPage loading state', () => {
it('loading.tsx renders while page data loads', () => {
// Simulate the Suspense boundary that App Router creates
function NeverResolves() {
throw new Promise<never>(() => {})
}
render(
<Suspense fallback={<DashboardLoading />}>
<NeverResolves />
</Suspense>
)
expect(screen.getByText('Loading your dashboard…')).toBeInTheDocument()
})
})Testing error.tsx
error.tsx must be a Client Component (it uses 'use client'). It receives error and reset props. The reset function re-renders the segment to retry the failed render.
// app/dashboard/error.tsx
'use client'
interface Props {
error: Error & { digest?: string }
reset: () => void
}
export default function DashboardError({ error, reset }: Props) {
return (
<div role="alert" className="error-state">
<h2>Something went wrong</h2>
<p>{error.message}</p>
{error.digest && (
<p className="error-state__digest">Error ID: {error.digest}</p>
)}
<button onClick={reset}>Try again</button>
</div>
)
}// app/dashboard/error.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import DashboardError from './error'
describe('DashboardError', () => {
it('displays the error message', () => {
const error = new Error('Failed to load orders') as Error & { digest?: string }
const reset = vi.fn()
render(<DashboardError error={error} reset={reset} />)
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByText('Failed to load orders')).toBeInTheDocument()
})
it('shows error digest when present', () => {
const error = Object.assign(new Error('Server error'), { digest: 'ERR_12345' })
const reset = vi.fn()
render(<DashboardError error={error} reset={reset} />)
expect(screen.getByText('Error ID: ERR_12345')).toBeInTheDocument()
})
it('calls reset when "Try again" is clicked', async () => {
const error = new Error('Server error') as Error & { digest?: string }
const reset = vi.fn()
render(<DashboardError error={error} reset={reset} />)
await userEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(reset).toHaveBeenCalledOnce()
})
it('does not show digest section when digest is absent', () => {
const error = new Error('Generic error') as Error & { digest?: string }
const reset = vi.fn()
render(<DashboardError error={error} reset={reset} />)
expect(screen.queryByText(/Error ID/)).not.toBeInTheDocument()
})
})Testing Route Handlers
Next.js 15 route handlers (app/api/**/route.ts) are async functions you can import and call directly in tests:
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const user = await db.users.findUnique({ where: { id } })
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json(user)
}Note: In Next.js 15, params is now a Promise. Tests must account for this:
// app/api/users/[id]/route.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
import { GET } from './route'
vi.mock('@/lib/db', () => ({
db: {
users: {
findUnique: vi.fn(),
},
},
}))
import { db } from '@/lib/db'
const makeRequest = (url = 'http://localhost/api/users/123') =>
new NextRequest(url)
const makeParams = (id: string) => ({ params: Promise.resolve({ id }) })
describe('GET /api/users/[id]', () => {
beforeEach(() => vi.clearAllMocks())
it('returns user when found', async () => {
vi.mocked(db.users.findUnique).mockResolvedValue({
id: '123',
name: 'Alice Chen',
email: 'alice@example.com',
})
const response = await GET(makeRequest(), makeParams('123'))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.name).toBe('Alice Chen')
})
it('returns 404 when user not found', async () => {
vi.mocked(db.users.findUnique).mockResolvedValue(null)
const response = await GET(makeRequest(), makeParams('999'))
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('User not found')
})
})Testing Nested Layouts
App Router supports nested layouts. Each segment can have its own layout.tsx, and they compose:
app/
layout.tsx ← root layout (html, body)
dashboard/
layout.tsx ← dashboard layout (sidebar, topbar)
settings/
layout.tsx ← settings layout (settings navigation)
page.tsxTest each layout in isolation, then test composition with Playwright:
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'
test('nested layout persists sidebar across settings pages', async ({ page }) => {
await page.goto('/dashboard/settings/profile')
// Root dashboard sidebar is present
await expect(page.getByTestId('sidebar')).toBeVisible()
// Settings-specific navigation is also present
await expect(page.getByTestId('settings-nav')).toBeVisible()
// Navigate to another settings page
await page.click('[data-testid="settings-nav"] >> text=Billing')
// Both layout elements persist — no full page reload
await expect(page.getByTestId('sidebar')).toBeVisible()
await expect(page.getByTestId('settings-nav')).toBeVisible()
await expect(page).toHaveURL('/dashboard/settings/billing')
})Testing Server Actions in Forms
Next.js 15 Server Actions can be used directly in forms. Testing them requires verifying the action runs and the UI updates accordingly:
// app/dashboard/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function updateProfile(formData: FormData) {
const name = formData.get('name') as string
await db.users.update({
where: { id: 'current-user' },
data: { name },
})
revalidatePath('/dashboard/settings')
}Unit test the action's database interaction:
// app/dashboard/actions.test.ts
import { describe, it, expect, vi } from 'vitest'
import { updateProfile } from './actions'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/db', () => ({
db: {
users: {
update: vi.fn().mockResolvedValue({ id: 'current-user', name: 'New Name' }),
},
},
}))
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
describe('updateProfile', () => {
it('updates user name and revalidates path', async () => {
const formData = new FormData()
formData.set('name', 'Alice Chen')
await updateProfile(formData)
expect(db.users.update).toHaveBeenCalledWith({
where: { id: 'current-user' },
data: { name: 'Alice Chen' },
})
expect(revalidatePath).toHaveBeenCalledWith('/dashboard/settings')
})
})What Automated Tests Miss
Unit and integration tests validate individual pieces, but they don't cover:
- Real navigation state — whether layouts actually persist across client-side navigation
- Streaming order — whether
loading.tsxappears before data in a real browser - Error boundary recovery — whether
reset()actually triggers a successful re-render against a live server - Concurrent requests — whether nested layouts under heavy load behave correctly
For live monitoring, HelpMeTest runs scheduled tests against your deployed Next.js app. When an error boundary gets stuck or a layout breaks after a deploy, you know before users do.
Summary
Next.js 15 App Router testing maps to these patterns:
layout.tsx→ render with children, assert structure and chromeloading.tsx→ test in isolation + test inside Suspense boundaryerror.tsx→ test error display, digest field, andresetfunction call- Route handlers → import directly, call with
NextRequest+{ params: Promise.resolve({}) } - Nested layouts → test each in isolation, use Playwright for composition
- Server Actions → unit test database calls and
revalidatePathseparately
The file conventions are predictable — write tests that match the contract each file is designed to fulfill.