Testing Next.js App Router: A Complete Guide to Vitest, Playwright, and Server Component Testing

Learn how to set up Vitest, Playwright, and MSW for Next.js App Router apps. Covers testing Server Components, Server Actions, Route Handlers, and mocking next/navigation — with working code for every pattern.

Why Testing the App Router Requires a New Strategy

If you've migrated from the Next.js Pages Router to the App Router, there's a good chance your existing test setup completely fell apart. And honestly, that's not surprising. The introduction of React Server Components, async data fetching at the component level, next/navigation replacing next/router, and Server Actions fundamentally changed how Next.js apps work — and how they need to be tested.

Here's the core problem: async Server Components can't be unit tested with Vitest or Jest. The official Next.js docs confirm this limitation and recommend end-to-end testing for async components. But that doesn't mean you should abandon unit testing altogether.

The right approach is a layered strategy that uses different tools for different parts of your application. This guide gives you a complete, working testing strategy for Next.js App Router apps in 2026 — covering unit tests with Vitest, E2E tests with Playwright, mocking with MSW, Route Handler testing, and Server Action testing.

The Testing Strategy: What Goes Where

Before writing a single test, you need a clear plan for which tool handles which part of your app. I've found this breakdown works best for App Router projects:

What You're TestingToolWhy
Client ComponentsVitest + React Testing LibraryFull render support, fast feedback loop
Synchronous Server ComponentsVitest + React Testing LibraryCan render without async context
Async Server ComponentsPlaywright (E2E)Requires full Next.js runtime for async rendering
Server ActionsVitest (logic) + Playwright (integration)Test business logic in isolation, test form submission E2E
Route HandlersNTARH or Vitest direct invocationEmulates Next.js request context without a running server
Full user flowsPlaywrightReal browser, real network, real rendering

Setting Up Vitest for Next.js App Router

Installation

First things first — install Vitest and its companion packages:

npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react \
  @testing-library/dom @testing-library/user-event @testing-library/jest-dom \
  vite-tsconfig-paths

Vitest Configuration

Create a vitest.config.ts in your project root. The two key decisions here are using jsdom as the test environment and including vite-tsconfig-paths so your path aliases (like @/) resolve correctly:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    include: ['**/*.{test,spec}.{ts,tsx}'],
    exclude: ['**/e2e/**', '**/node_modules/**'],
  },
});

Setup File

Create vitest.setup.ts to handle cleanup and global matchers:

import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

afterEach(() => {
  cleanup();
  vi.clearAllMocks();
});

Add the Test Script

Then add test scripts to your package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Mocking next/navigation in Vitest

This is probably the most common pain point when testing App Router components. Unlike the old next/router, the App Router uses useRouter, usePathname, and useSearchParams from next/navigation — and they all rely on React context that simply doesn't exist in a test environment.

Option 1: Manual Mock (Recommended for Most Projects)

Create a mock directly in your test or setup file. This is the approach I'd recommend starting with — it's explicit and easy to customize:

import { vi } from 'vitest';

const mockPush = vi.fn();
const mockReplace = vi.fn();
const mockBack = vi.fn();
const mockPrefetch = vi.fn();

vi.mock('next/navigation', () => ({
  useRouter: () => ({
    push: mockPush,
    replace: mockReplace,
    back: mockBack,
    prefetch: mockPrefetch,
    refresh: vi.fn(),
  }),
  usePathname: () => '/dashboard',
  useSearchParams: () => new URLSearchParams(),
  useParams: () => ({}),
  redirect: vi.fn(),
  notFound: vi.fn(),
}));

Option 2: Using next-router-mock

If you need stateful router behavior — where pushing a route actually changes the return value of usePathname — use next-router-mock:

npm install -D next-router-mock

Then in your test:

import mockRouter from 'next-router-mock';
import { vi } from 'vitest';

vi.mock('next/navigation', () =>
  vi.importActual('next-router-mock/navigation')
);

// In your test:
mockRouter.push('/initial-path');
render();
// The component sees pathname as '/initial-path'

Testing Client Components

Client Components are the most straightforward to test. They render entirely in the browser-like jsdom environment, and you can interact with them using React Testing Library. Nothing fancy here — it's basically standard React testing:

// components/SearchBar.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export function SearchBar() {
  const [query, setQuery] = useState('');
  const router = useRouter();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (query.trim()) {
      router.push(`/search?q=${encodeURIComponent(query)}`);
    }
  };

  return (
    
setQuery(e.target.value)} placeholder="Search..." aria-label="Search" />
); }
// components/SearchBar.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { SearchBar } from './SearchBar';

const mockPush = vi.fn();

vi.mock('next/navigation', () => ({
  useRouter: () => ({ push: mockPush }),
}));

describe('SearchBar', () => {
  it('navigates to search results on submit', async () => {
    const user = userEvent.setup();
    render();

    await user.type(screen.getByLabelText('Search'), 'next.js testing');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    expect(mockPush).toHaveBeenCalledWith(
      '/search?q=next.js%20testing'
    );
  });

  it('does not navigate when query is empty', async () => {
    const user = userEvent.setup();
    render();

    await user.click(screen.getByRole('button', { name: 'Search' }));

    expect(mockPush).not.toHaveBeenCalled();
  });
});

Testing Synchronous Server Components

Synchronous Server Components — the ones that don't use async/await — can be unit tested with Vitest just like Client Components. The key difference? They don't have hooks or state, so the tests tend to be simpler:

// components/UserCard.tsx
type UserCardProps = {
  name: string;
  email: string;
  role: 'admin' | 'user';
};

export function UserCard({ name, email, role }: UserCardProps) {
  return (
    

{name}

{email}

{role === 'admin' && ( Admin )}
); }
// components/UserCard.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { UserCard } from './UserCard';

describe('UserCard', () => {
  it('renders user information', () => {
    render();

    expect(screen.getByText('Jane Doe')).toBeInTheDocument();
    expect(screen.getByText('[email protected]')).toBeInTheDocument();
  });

  it('shows admin badge for admin users', () => {
    render();

    expect(screen.getByText('Admin')).toBeInTheDocument();
  });

  it('hides admin badge for regular users', () => {
    render();

    expect(screen.queryByText('Admin')).not.toBeInTheDocument();
  });
});

Testing Server Actions

Server Actions are functions marked with "use server" that run on the server. Testing them requires a two-part approach: test the business logic in isolation with Vitest, and test the form integration end-to-end with Playwright.

Extracting Testable Logic

Here's a pattern I keep coming back to — separate your validation and business logic from the Server Action itself. This makes the logic independently testable without needing the server runtime (and your tests will be way faster too):

// lib/validation.ts
export type ContactFormData = {
  name: string;
  email: string;
  message: string;
};

export type ValidationResult = {
  success: boolean;
  errors: Record;
};

export function validateContactForm(data: ContactFormData): ValidationResult {
  const errors: Record = {};

  if (!data.name || data.name.trim().length < 2) {
    errors.name = 'Name must be at least 2 characters';
  }

  if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
    errors.email = 'Please enter a valid email address';
  }

  if (!data.message || data.message.trim().length < 10) {
    errors.message = 'Message must be at least 10 characters';
  }

  return {
    success: Object.keys(errors).length === 0,
    errors,
  };
}
// lib/validation.test.ts
import { describe, it, expect } from 'vitest';
import { validateContactForm } from './validation';

describe('validateContactForm', () => {
  it('passes with valid data', () => {
    const result = validateContactForm({
      name: 'Jane Doe',
      email: '[email protected]',
      message: 'Hello, this is a test message',
    });

    expect(result.success).toBe(true);
    expect(result.errors).toEqual({});
  });

  it('fails with short name', () => {
    const result = validateContactForm({
      name: 'J',
      email: '[email protected]',
      message: 'Hello, this is a test message',
    });

    expect(result.success).toBe(false);
    expect(result.errors.name).toBeDefined();
  });

  it('fails with invalid email', () => {
    const result = validateContactForm({
      name: 'Jane Doe',
      email: 'not-an-email',
      message: 'Hello, this is a test message',
    });

    expect(result.success).toBe(false);
    expect(result.errors.email).toBeDefined();
  });
});

The Server Action Itself

// actions/contact.ts
'use server';

import { validateContactForm } from '@/lib/validation';

export async function submitContactForm(formData: FormData) {
  const data = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    message: formData.get('message') as string,
  };

  const validation = validateContactForm(data);
  if (!validation.success) {
    return { success: false, errors: validation.errors };
  }

  // Save to database, send email, etc.
  // await db.insert(contacts).values(data);

  return { success: true, errors: {} };
}

This way, Vitest handles the validation logic (which runs in milliseconds), and Playwright handles the full form submission flow through the actual Next.js server. Best of both worlds.

Testing Route Handlers with NTARH

App Router Route Handlers (the route.ts files) are honestly one of the trickiest things to test. They depend on Web API objects like Request and Response, plus Next.js-specific helpers like cookies() and headers(). The next-test-api-route-handler (NTARH) package solves this by emulating the Next.js request pipeline:

npm install -D next-test-api-route-handler

Important: NTARH must always be the first import in your test file. This trips people up constantly — if it's not first, you'll get weird errors.

// app/api/users/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const users = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ];
  return NextResponse.json(users);
}

export async function POST(request: Request) {
  const body = await request.json();

  if (!body.name || typeof body.name !== 'string') {
    return NextResponse.json(
      { error: 'Name is required' },
      { status: 400 }
    );
  }

  const newUser = { id: 3, name: body.name };
  return NextResponse.json(newUser, { status: 201 });
}
// app/api/users/route.test.ts
import { testApiHandler } from 'next-test-api-route-handler'; // Must be first!
import * as appHandler from './route';
import { describe, it, expect } from 'vitest';

describe('/api/users', () => {
  it('GET returns a list of users', async () => {
    await testApiHandler({
      appHandler,
      test: async ({ fetch }) => {
        const response = await fetch({ method: 'GET' });
        const users = await response.json();

        expect(response.status).toBe(200);
        expect(users).toHaveLength(2);
        expect(users[0]).toHaveProperty('name', 'Alice');
      },
    });
  });

  it('POST creates a new user', async () => {
    await testApiHandler({
      appHandler,
      test: async ({ fetch }) => {
        const response = await fetch({
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ name: 'Charlie' }),
        });
        const user = await response.json();

        expect(response.status).toBe(201);
        expect(user.name).toBe('Charlie');
      },
    });
  });

  it('POST returns 400 without a name', async () => {
    await testApiHandler({
      appHandler,
      test: async ({ fetch }) => {
        const response = await fetch({
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({}),
        });

        expect(response.status).toBe(400);
      },
    });
  });
});

Testing Dynamic Route Handlers

For routes with dynamic segments like /api/users/[id], just pass the params option:

await testApiHandler({
  appHandler,
  params: { id: '42' },
  test: async ({ fetch }) => {
    const response = await fetch({ method: 'GET' });
    const user = await response.json();
    expect(user.id).toBe(42);
  },
});

Setting Up Playwright for E2E Testing

Installation

npm init playwright@latest

This installs Playwright and creates a playwright.config.ts. Configure it to start your Next.js dev server automatically:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  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,
  },
});

Testing Async Server Components

So, here's where Playwright really earns its keep. Async Server Components fetch data at render time, and since they can't be unit tested, Playwright is where you verify they actually work:

// e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Dashboard Page', () => {
  test('displays user stats from the database', async ({ page }) => {
    await page.goto('/dashboard');

    // Wait for the async server component to render
    await expect(page.getByRole('heading', { name: 'Dashboard' }))
      .toBeVisible();

    // Verify data from async fetch is displayed
    await expect(page.getByTestId('total-users')).toBeVisible();
    await expect(page.getByTestId('recent-activity')).toBeVisible();
  });

  test('handles loading states correctly', async ({ page }) => {
    await page.goto('/dashboard');

    // Check that suspense fallback appears then resolves
    const statsSection = page.getByTestId('stats-section');
    await expect(statsSection).toBeVisible();
  });
});

Testing Server Action Form Submissions

// e2e/contact.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Contact Form', () => {
  test('submits the form successfully', async ({ page }) => {
    await page.goto('/contact');

    await page.getByLabel('Name').fill('Jane Doe');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Message').fill('This is a test message for the contact form');

    await page.getByRole('button', { name: 'Send Message' }).click();

    await expect(page.getByText('Message sent successfully'))
      .toBeVisible();
  });

  test('shows validation errors for invalid input', async ({ page }) => {
    await page.goto('/contact');

    // Submit empty form
    await page.getByRole('button', { name: 'Send Message' }).click();

    await expect(page.getByText('Name must be at least 2 characters'))
      .toBeVisible();
    await expect(page.getByText('Please enter a valid email'))
      .toBeVisible();
  });
});

API Mocking with Mock Service Worker (MSW)

When your components fetch data from external APIs, you need to mock those calls in tests. MSW intercepts requests at the network level, which means your mocks work identically in both unit tests and E2E tests. That consistency alone makes it worth using.

Setup for Vitest

npm install -D msw

Create your mock handlers:

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('https://api.example.com/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice', email: '[email protected]' },
      { id: 2, name: 'Bob', email: '[email protected]' },
    ]);
  }),

  http.post('https://api.example.com/users', async ({ request }) => {
    const body = await request.json() as { name: string; email: string };
    return HttpResponse.json(
      { id: 3, name: body.name, email: body.email },
      { status: 201 }
    );
  }),
];

Set up the MSW server for Node.js (used by Vitest):

// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

Wire it into your Vitest setup file:

// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server } from './mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
  cleanup();
  server.resetHandlers();
  vi.clearAllMocks();
});
afterAll(() => server.close());

Per-Test Handler Overrides

You can override handlers in individual tests to simulate error states. This is really useful for testing how your UI handles API failures:

import { http, HttpResponse } from 'msw';
import { server } from '@/mocks/server';

it('shows error state when API fails', async () => {
  server.use(
    http.get('https://api.example.com/users', () => {
      return HttpResponse.json(
        { error: 'Internal Server Error' },
        { status: 500 }
      );
    })
  );

  render();

  await waitFor(() => {
    expect(screen.getByText('Failed to load users')).toBeInTheDocument();
  });
});

MSW with Playwright for Server-Side Mocking

Starting with Next.js 15, you can enable the experimental testProxy feature to mock server-side fetch calls during Playwright tests:

// next.config.ts
const nextConfig = {
  experimental: {
    testProxy: true,
  },
};

export default nextConfig;

This lets MSW intercept requests made by Server Components during E2E tests, which is pretty huge — it closes the gap between unit test mocks and end-to-end test behavior.

Organizing Your Test Files

A well-organized test structure makes it obvious what's being tested and how. Here's the layout I'd recommend for App Router projects:

project-root/
├── app/
│   ├── dashboard/
│   │   ├── page.tsx
│   │   └── __tests__/
│   │       └── page.test.tsx          # Sync component tests
│   └── api/
│       └── users/
│           ├── route.ts
│           └── route.test.ts          # NTARH route tests
├── components/
│   ├── SearchBar.tsx
│   └── SearchBar.test.tsx             # Co-located component tests
├── lib/
│   ├── validation.ts
│   └── validation.test.ts            # Pure logic tests
├── e2e/
│   ├── dashboard.spec.ts             # Playwright E2E tests
│   └── contact.spec.ts
├── mocks/
│   ├── handlers.ts                   # MSW handlers
│   └── server.ts                     # MSW server setup
├── vitest.config.ts
├── vitest.setup.ts
└── playwright.config.ts

Keep unit tests co-located with the code they test. Keep E2E tests in a separate e2e/ directory. This separation also makes it easy to exclude E2E tests from Vitest (and vice versa) in your config files.

Mocking next/headers in Server-Side Tests

If you have utility functions that call cookies() or headers() from next/headers, you'll need to mock them since they require the Next.js async request context:

vi.mock('next/headers', () => ({
  cookies: vi.fn().mockReturnValue({
    get: vi.fn().mockReturnValue({ value: 'mock-session-token' }),
    set: vi.fn(),
    delete: vi.fn(),
  }),
  headers: vi.fn().mockReturnValue(
    new Map([['authorization', 'Bearer mock-token']])
  ),
}));

For Route Handler tests specifically, just use NTARH instead — it provides real cookies() and headers() context without any manual mocking. One less thing to worry about.

Running Tests in CI/CD

A typical GitHub Actions workflow runs unit tests and E2E tests in sequence. Here's a setup that's worked well in production:

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run test:run

  e2e-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Running unit tests first gives you a fast fail signal. E2E tests only run if unit tests pass, which saves CI minutes on broken builds. Small thing, but it adds up.

Common Pitfalls and How to Avoid Them

1. "Request is not defined" in Jest/Vitest

Route Handlers use the Web Request and Response APIs, which aren't available in jsdom. Use NTARH for route tests, or set environment: 'node' for those specific test files using an inline config comment:

// @vitest-environment node

2. Mixing Server and Client Imports

A test file that imports a Server Component using cookies() and also imports a Client Component using useState will fail. Keep server-side tests and client-side tests in separate files. Seriously, don't try to combine them — it's not worth the headache.

3. Testing Implementation Instead of Behavior

Don't test whether a Server Component called fetch with specific arguments. Instead, test that the rendered output shows the correct data. Implementation details change — behavior contracts don't.

4. Forgetting to Reset Mocks

MSW handler overrides persist across tests if you forget to call server.resetHandlers(). Always do this in afterEach to prevent test pollution. This one will cause the most confusing bugs if you skip it.

Frequently Asked Questions

Can I unit test async Server Components with Vitest?

No. As of 2026, Vitest and Jest don't support rendering async Server Components. These components need the full Next.js server runtime to resolve their async data fetching. Use Playwright E2E tests for async Server Components, and keep Vitest for synchronous Server Components, Client Components, utility functions, and business logic.

What's the best testing framework for Next.js App Router — Jest or Vitest?

Vitest is the recommended choice for new App Router projects. It offers native ESM support (which avoids the CommonJS compatibility headaches that are common with Jest), faster execution, a nearly identical API to Jest, and first-class TypeScript support without extra config. The official Next.js docs list both options, but Vitest has fewer friction points with modern App Router projects.

How do I test Next.js Route Handlers without starting the dev server?

Use the next-test-api-route-handler (NTARH) package. It emulates the Next.js request pipeline internally, giving you real Request/Response objects and support for cookies(), headers(), and dynamic route params — all without running a server. Just import your route handler and pass it to testApiHandler() along with your assertions.

How should I mock external API calls in Server Components?

Use Mock Service Worker (MSW). For Vitest unit tests, use setupServer from msw/node to intercept fetch calls. For Playwright E2E tests, enable the experimental testProxy option in next.config.ts so MSW can intercept server-side fetch calls made during SSR. Since MSW works at the network level, your mocks behave the same across all test types.

How do I test Server Actions in Next.js?

Use a two-part strategy. Extract your validation and business logic into pure functions and test those with Vitest — you'll get fast, reliable unit tests. Then test the full form submission flow (including the "use server" action invocation) with Playwright E2E tests, which run through the actual Next.js server and verify the real integration works end to end.

About the Author Editorial Team

Our team of expert writers and editors.