Next.js 15 App Router Testing: Layouts, Loading, Error, and Route Handlers

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-dom

vitest.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.tsx

Test 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.tsx appears 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 chrome
  • loading.tsx → test in isolation + test inside Suspense boundary
  • error.tsx → test error display, digest field, and reset function 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 revalidatePath separately

The file conventions are predictable — write tests that match the contract each file is designed to fulfill.

Read more