Testing Next.js Apps: Jest, React Testing Library, and Playwright (2026)
Next.js gives you a lot out of the box: routing, server components, API routes, middleware, server actions. Each of those layers can break in ways that only show up in production if you don't have tests covering them.
This guide covers the full testing stack for a Next.js app in 2026: unit testing components with Jest and React Testing Library, testing API routes directly, handling the quirks of server components, end-to-end testing with Playwright, and what your test suite still can't catch once the app is live.
Setting Up Jest for Next.js
Next.js 13+ has built-in Jest support. From the project root:
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-eventInitialize the config:
npx create-next-app@latest --example with-jest my-app
# or configure manually:// jest.config.js
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterFramework: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
module.exports = createJestConfig(customJestConfig)// jest.setup.js
import '@testing-library/jest-dom'next/jest handles the heavy lifting — it configures Babel/SWC transforms, mocks CSS and image imports, and sets up next/navigation stubs automatically.
Unit Testing Client Components
Client components (marked 'use client' or plain .tsx files in the pages/ directory) are the most straightforward to test. React Testing Library is the right tool: render the component, interact with it, assert the outcome.
// components/SearchBar.tsx
'use client'
import { useState } from 'react'
interface Props {
onSearch: (query: string) => void
}
export function SearchBar({ onSearch }: Props) {
const [query, setQuery] = useState('')
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (query.trim()) {
onSearch(query.trim())
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
aria-label="Search"
/>
<button type="submit" disabled={!query.trim()}>
Search
</button>
</form>
)
}// components/SearchBar.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SearchBar } from './SearchBar'
describe('SearchBar', () => {
it('calls onSearch with trimmed query when form is submitted', async () => {
const user = userEvent.setup()
const onSearch = jest.fn()
render(<SearchBar onSearch={onSearch} />)
await user.type(screen.getByRole('textbox', { name: /search/i }), ' next.js testing ')
await user.click(screen.getByRole('button', { name: /search/i }))
expect(onSearch).toHaveBeenCalledWith('next.js testing')
expect(onSearch).toHaveBeenCalledTimes(1)
})
it('disables the submit button when input is empty', () => {
render(<SearchBar onSearch={jest.fn()} />)
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
})
it('does not call onSearch for whitespace-only input', async () => {
const user = userEvent.setup()
const onSearch = jest.fn()
render(<SearchBar onSearch={onSearch} />)
await user.type(screen.getByRole('textbox', { name: /search/i }), ' ')
await user.click(screen.getByRole('button', { name: /search/i }))
expect(onSearch).not.toHaveBeenCalled()
})
})Use userEvent from @testing-library/user-event instead of fireEvent wherever possible — it simulates real browser events including focus, blur, and input event sequencing that fireEvent skips.
Testing Next.js API Routes
API routes in the app/ directory are plain async functions that take a Request and return a Response. You can test them directly without spinning up a server.
// app/api/products/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category')
if (!category) {
return NextResponse.json({ error: 'category is required' }, { status: 400 })
}
const products = await db.products.findMany({
where: { category },
select: { id: true, name: true, price: true },
})
return NextResponse.json({ products })
}// app/api/products/route.test.ts
import { GET } from './route'
import { db } from '@/lib/db'
jest.mock('@/lib/db', () => ({
db: {
products: {
findMany: jest.fn(),
},
},
}))
const mockDb = db as jest.Mocked<typeof db>
describe('GET /api/products', () => {
afterEach(() => {
jest.clearAllMocks()
})
it('returns 400 when category param is missing', async () => {
const request = new Request('http://localhost/api/products')
const response = await GET(request)
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('category is required')
})
it('returns products filtered by category', async () => {
mockDb.products.findMany.mockResolvedValue([
{ id: '1', name: 'Widget A', price: 9.99 },
{ id: '2', name: 'Widget B', price: 14.99 },
])
const request = new Request('http://localhost/api/products?category=widgets')
const response = await GET(request)
expect(response.status).toBe(200)
const body = await response.json()
expect(body.products).toHaveLength(2)
expect(mockDb.products.findMany).toHaveBeenCalledWith({
where: { category: 'widgets' },
select: { id: true, name: true, price: true },
})
})
it('propagates database errors', async () => {
mockDb.products.findMany.mockRejectedValue(new Error('DB connection lost'))
const request = new Request('http://localhost/api/products?category=widgets')
await expect(GET(request)).rejects.toThrow('DB connection lost')
})
})Testing API routes this way is fast — no HTTP overhead, no server startup. You're testing the function logic directly.
Testing Server Components
Server components run on the server, can be async, and have no browser APIs. The tricky part: they can't be rendered with the standard jsdom environment because they use Node.js primitives.
For components that fetch data, the practical approach is to separate the data-fetching logic from the rendering logic, then test each independently.
// components/ProductList.tsx
import { getProducts } from '@/lib/products'
// Data fetching extracted to a testable function
export async function ProductList({ category }: { category: string }) {
const products = await getProducts(category)
if (products.length === 0) {
return <p>No products found in {category}.</p>
}
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — ${p.price.toFixed(2)}
</li>
))}
</ul>
)
}// lib/products.test.ts — test the data layer
import { getProducts } from './products'
import { db } from './db'
jest.mock('./db')
const mockDb = db as jest.Mocked<typeof db>
describe('getProducts', () => {
it('returns products for the given category', async () => {
mockDb.products.findMany.mockResolvedValue([
{ id: '1', name: 'Widget', price: 9.99 },
])
const result = await getProducts('widgets')
expect(result).toHaveLength(1)
expect(result[0].name).toBe('Widget')
})
it('returns empty array when no products match', async () => {
mockDb.products.findMany.mockResolvedValue([])
const result = await getProducts('nonexistent-category')
expect(result).toEqual([])
})
})For the rendering half, there are two options. The @testing-library/react server component support is still evolving — for now, the most reliable pattern is to test the UI behavior through E2E tests (Playwright) rather than trying to unit-test async server components in jsdom.
Testing with Playwright for E2E
Playwright is the right tool for end-to-end tests. It runs a real browser, handles navigation, form submission, authentication state, and all the async behavior that unit tests miss.
Install Playwright:
npm install -D @playwright/test
npx playwright install// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})A realistic E2E test for a product search flow:
// e2e/product-search.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Product search', () => {
test('user can search for products and see results', async ({ page }) => {
await page.goto('/products')
// Verify search input is present
await expect(page.getByRole('textbox', { name: /search/i })).toBeVisible()
// Search for a product
await page.getByRole('textbox', { name: /search/i }).fill('widget')
await page.getByRole('button', { name: /search/i }).click()
// Wait for results to load
await page.waitForURL(/\?q=widget/)
const results = page.getByRole('listitem')
await expect(results.first()).toBeVisible()
// Verify results contain the search term
const count = await results.count()
expect(count).toBeGreaterThan(0)
})
test('empty search shows no results message', async ({ page }) => {
await page.goto('/products?q=xqzrtplmnonsense')
await expect(page.getByText(/no products found/i)).toBeVisible()
})
})Run E2E tests:
npx playwright test <span class="hljs-comment"># all tests
npx playwright <span class="hljs-built_in">test --ui <span class="hljs-comment"># interactive mode
npx playwright <span class="hljs-built_in">test --headed <span class="hljs-comment"># see the browserWhat Code Tests Miss
Unit tests and E2E tests cover your code. They do not cover what happens after you deploy.
Consider what breaks in production that no local test catches:
- Third-party API failures — your payment provider returns 503, your image CDN goes down, your search index is stale
- Database query timeouts — a query that takes 20ms locally takes 8 seconds against production data
- Environment variable gaps — a config value works in CI but is missing or wrong in prod
- Server component waterfall latency — nested async server components that complete in 200ms locally take 3 seconds in production under load
- Cold start regressions — a serverless function that previously cold-started in 400ms now takes 2.5 seconds after a dependency upgrade
These failures show up as user-visible errors: pages that load forever, checkout flows that silently drop, forms that submit to nowhere. Your test suite was green. Your users are gone.
The gap between "tests passing" and "working in production" is exactly what production monitoring covers. You need checks running against your live app — not just before deploy.
Adding Production Monitoring with HelpMeTest
HelpMeTest runs plain-English test scripts against your live Next.js app on a schedule. When something breaks in production, you find out in minutes instead of from a user ticket.
Install the CLI:
curl -fsSL https://helpmetest.com/install | bashWrite a test in plain English:
Go to https://your-app.com/products
Type "widget" in the search field
Click the Search button
Verify the results list is visible
Verify at least one result contains "Widget"Run it on a schedule — every 5 minutes, every hour, whatever matches your SLA. If the search breaks, if the results page returns a 500, if the Next.js server component fails to render — you get an alert before users do.
The free tier includes 10 tests and unlimited health checks. The Pro plan is $100/month flat. No per-seat pricing.
Test Coverage That Actually Matters
A realistic baseline for a production Next.js app:
| Layer | Tool | What to cover |
|---|---|---|
| Client components | Jest + RTL | User interactions, conditional rendering, form validation |
| API routes | Jest | Happy path, validation errors, DB errors, auth guards |
| Data layer | Jest | Queries, transformations, error propagation |
| Full flows | Playwright | Auth, critical user journeys, forms |
| Production | HelpMeTest | Smoke tests, health checks, 3rd-party dependencies |
The goal is not 100% code coverage. The goal is confidence that the things users rely on work — before they have to tell you otherwise.
Monitor your Next.js app in production. Free tier: 10 tests, unlimited health checks. Pro: $100/month flat.
Install the CLI: curl -fsSL https://helpmetest.com/install | bash — then visit helpmetest.com to connect your app.