TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Test Next.js API Routes with Vitest

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20266 min read5 topics
How to Test Next.js API Routes with Vitest
Explore the details
#Next.js#Vitest#API testing#Route Handlers#JavaScript testing
October 26, 20266 min read

Testing a Next.js API route does not require opening a browser. For an App Router Route Handler, you can call its exported function with a standard Request and inspect the returned Response. This tutorial sets up Vitest, mocks a service dependency so tests cannot reach a real database, and covers successful, invalid, and failing requests.

Key Takeaways

  • App Router API endpoints are Route Handlers, usually defined in app/api/.../route.ts.
  • Calling a handler directly is a handler-level unit test; it does not exercise browser behavior, Next.js routing, or deployment infrastructure.
  • Mock external services to keep tests isolated and predictable.
  • Use browser-based integration tests when you need to verify the running application and its routing behavior.

Set up Vitest

This example assumes an existing Next.js project with TypeScript and a @/* path alias. It uses Node's test environment because the route test does not render React components.

Install Vitest and a Vite plugin that reads TypeScript path aliases:

npm install --save-dev vitest vite-tsconfig-paths

Add a Vitest configuration at the project root:

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [tsconfigPaths()],
  test: {
    environment: 'node',
    include: ['**/*.test.{ts,tsx}'],
    clearMocks: true,
    restoreMocks: true,
  },
})

Add test scripts to package.json:

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

Use npm test while developing, or npm run test:run for a one-time run suitable for CI. This config is for route tests, not React component tests; a project that also tests rendered UI may need a DOM environment and additional setup.

Create a Route Handler with a mockable dependency

The example endpoint accepts a non-empty name and returns a created item. Keep the service in a separate module so the route's behavior can be tested without making a real database or network call.

// lib/items.ts
import { randomUUID } from 'node:crypto'

export async function createItem(name: string) {
  // Replace this demonstration implementation with your persistence layer.
  return { id: randomUUID(), name }
}

This service returns an object but does not persist it. In a real application, put database access in this module and keep the route responsible for HTTP-level parsing, validation, and response codes.

// app/api/items/route.ts
import { NextResponse } from 'next/server'
import { createItem } from '@/lib/items'

export async function POST(request: Request) {
  let payload: unknown

  try {
    payload = await request.json()
  } catch {
    return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
  }

  if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
    return NextResponse.json({ error: 'Expected a JSON object' }, { status: 400 })
  }

  const name = (payload as { name?: unknown }).name
  if (typeof name !== 'string' || name.trim().length === 0) {
    return NextResponse.json({ error: 'Name is required' }, { status: 400 })
  }

  try {
    const item = await createItem(name.trim())
    return NextResponse.json({ item }, { status: 201 })
  } catch {
    // Keep internal exception details out of the HTTP response.
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
}

The handler accepts the standard Request type and returns a NextResponse. It handles malformed JSON separately from a valid JSON value with invalid fields, and it does not expose service exception details to the caller.

Test success, invalid input, and service errors

Place the test beside the route as app/api/items/route.test.ts. The module mock replaces createItem before the handler is imported, so these tests cannot accidentally invoke the real service. Each test sets the mock behavior it needs.

// app/api/items/route.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createItem } from '@/lib/items'
import { POST } from './route'

vi.mock('@/lib/items', () => ({
  createItem: vi.fn(),
}))

function post(body: string) {
  return new Request('http://localhost/api/items', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body,
  })
}

describe('POST /api/items', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('creates an item and returns 201', async () => {
    vi.mocked(createItem).mockResolvedValue({ id: 'item-1', name: 'Ada' })

    const response = await POST(post(JSON.stringify({ name: ' Ada ' })))

    expect(response.status).toBe(201)
    expect(await response.json()).toEqual({
      item: { id: 'item-1', name: 'Ada' },
    })
    expect(createItem).toHaveBeenCalledWith('Ada')
  })

  it('returns 400 for invalid input without calling the service', async () => {
    const response = await POST(post(JSON.stringify({ name: '   ' })))

    expect(response.status).toBe(400)
    expect(await response.json()).toEqual({ error: 'Name is required' })
    expect(createItem).not.toHaveBeenCalled()
  })

  it('returns 400 for malformed JSON', async () => {
    const response = await POST(post('{'))

    expect(response.status).toBe(400)
    expect(await response.json()).toEqual({ error: 'Invalid JSON' })
    expect(createItem).not.toHaveBeenCalled()
  })

  it('returns a generic 500 when the service rejects', async () => {
    vi.mocked(createItem).mockRejectedValue(new Error('Database details'))

    const response = await POST(post(JSON.stringify({ name: 'Ada' })))

    expect(response.status).toBe(500)
    expect(await response.json()).toEqual({ error: 'Internal server error' })
  })
})

Run the tests with npm run test:run. The success case checks the status, response body, and normalized service argument. The invalid-input cases check that the service is not called. The error case verifies that a service failure becomes a generic response rather than leaking its exception message.

Handler unit tests versus browser integration tests

Calling POST directly tests the handler's logic with an in-memory request. It does not start the Next.js server or verify that a URL maps to the expected route. It also does not exercise middleware, browser behavior, deployment configuration, or interactions with a real database when that dependency is mocked.

Use this style for fast, focused checks of parsing, validation, status codes, and how the handler responds to service outcomes. Use a browser integration test when the goal is to visit the running application and verify end-to-end behavior through its actual routing and browser environment. These approaches answer different questions; a passing handler test is not proof that the deployed endpoint works end to end.

Common Mistakes

  • Testing a Pages Router API file as an App Router handler. This example targets app/api/.../route.ts. Pages Router endpoints use a different request and response interface.
  • Using a browser environment unnecessarily. A route handler test that only uses Request and Response can run in Node; adding a DOM environment does not make it an end-to-end test.
  • Letting tests call real services. Mock database, email, payment, or network dependencies when the test is intended to isolate handler behavior.
  • Mocking away the behavior under test. Mock the dependency, not the route's parsing or response logic.
  • Treating a direct function call as a routing test. Importing and calling POST does not verify that Next.js dispatches an HTTP request to that handler.
  • Returning internal error details. Assert a safe client response; log diagnostic details through an appropriate server-side mechanism if the application needs them.

Best Practices

  • Keep request validation close to the route boundary and test malformed bodies as well as invalid fields.
  • Make dependencies explicit through imported service modules so tests can replace them reliably.
  • Give each test its own mock result, including rejected promises for failure paths.
  • Keep a small number of browser or server integration tests for behavior that direct handler calls cannot cover.
  • Check your installed Next.js and Vitest documentation when adapting configuration: versions and project setups can differ. Live documentation retrieval was not available while preparing this article, so the configuration above is not presented as verified against the latest releases.

Conclusion

Vitest can test an App Router Route Handler by calling its exported method with a standard Request. Mocking the service keeps those tests focused on HTTP behavior, while browser integration tests remain necessary for verifying the running application's routing and user-facing behavior.