Skip to content
intermediate Phase 13 · Testing Next.js Applications

Testing Route Handlers

Invoke handlers with constructed Request objects.

45m
0 problems
Topic Progress 0%

Calling a Route Handler with a Hand-Built Request: No Server, No Ports

An App Router route handler is not a framework abstraction you need a server to reach. It is a plain function that takes a Web Request and returns a Response:

// app/api/orders/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params
  const order = await findOrder(id)
  if (!order) return Response.json({ error: 'not_found' }, { status: 404 })
  return Response.json(order)
}

Because the signature uses web standards, a test can import the export and call it directly — no next start, no port, no HTTP client:

import { describe, it, expect } from 'vitest'
import { GET, PATCH } from '@/app/api/orders/[id]/route'

it('returns the order as JSON', async () => {
  const res = await GET(new Request('http://localhost/api/orders/42'), {
    params: Promise.resolve({ id: '42' }),
  })

  expect(res.status).toBe(200)
  expect(res.headers.get('content-type')).toContain('application/json')
  await expect(res.json()).resolves.toMatchObject({ id: '42' })
})

it('returns 404 for an unknown order', async () => {
  const res = await GET(new Request('http://localhost/api/orders/404'), {
    params: Promise.resolve({ id: '404' }),
  })
  expect(res.status).toBe(404)
})

The params argument is where most first attempts fail. Since Next 15, segment data arrives as a Promise, so the test passes Promise.resolve({ id: '42' }); destructure only inside the handler. Passing the raw object makes await params yield an object shape your handler never sees in production, and the test passes for the wrong reasons.

Constructing the request exercises every branch the same way the wire would:

const post = new Request('http://localhost/api/orders', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ sku: 'SLATE-1', qty: 2 }),
})

const bad = new Request('http://localhost/api/orders', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: '{ not json',
})

await request.json() inside your handler parses that body exactly as it would in production — a malformed-body test costs nothing. Query strings work too: new URL(request.url).searchParams.get('cursor'), which is how you cover pagination without a URL builder.

Also worth pinning: how the handler exits. Response.json() and NextResponse.json() are interchangeable in tests — both give you a plain Response with the same assertion surface. redirect('/login') behaves differently: it works by throwing, so the thrown error carries a digest like NEXT_REDIRECT;replace;/login;307; and a direct test asserts it as a rejection:

it('sends anonymous visitors to login', async () => {
  await expect(GET(new Request('http://localhost/api/orders')))
    .rejects.toMatchObject({ digest: expect.stringContaining('NEXT_REDIRECT') })
})

Wrap the call in expect(...).rejects rather than letting the throw surface as an unhandled test failure — the error is the contract here, and next-test-api-route-handler resolves it into a real 307 when you need the status instead of the digest.

What you are not covering yet matters more than what you are. A bare new Request has no cookies(), no headers() from next/headers, no request.nextUrl, and no access to revalidatePath — those are injected by Next's request pipeline, and a handler that reads them will throw outside it. The rule of thumb:

Handler feature Direct new Request test Needs Next's pipeline
Status, JSON body, headers yes no
request.url, method, body parsing yes no
params / searchParams yes (wrapped in a Promise) no
nextUrl, cookies(), headers() from next/headers no yes — next-test-api-route-handler
revalidatePath / revalidateTag side effects no yes

That boundary is the whole design of this layer: start with the direct call because it is fast and hermetic, and reach for the pipeline wrapper only when the handler genuinely depends on Next's runtime — which the next chapter sets up alongside the network mocks. Contrast this with Component Tests with Testing Library: there the DOM was the subject; here there is no DOM at all, and the contract is status code plus body.

Isolating the Handler: msw, fetch Stubs, and next-test-api-route-handler

Your handler probably does not only read its own arguments — it calls a payment API, a database client, or an internal service. In a test those calls are liabilities: they make CI depend on someone else's uptime, rate limits, and latency. Two tools solve this, and picking between them is a scope decision.

Stub fetch when one call decides the assertion. If the handler makes a single outbound request and you care about the exact URL and payload:

const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)

afterEach(() => vi.unstubAllGlobals())

it('maps a delivered shipment to status "shipped"', async () => {
  fetchMock.mockResolvedValue(Response.json({ status: 'delivered' }))

  const res = await GET(new Request('http://localhost/api/orders/42/track'), {
    params: Promise.resolve({ id: '42' }),
  })

  expect(await res.json()).toEqual({ status: 'shipped' })
  expect(fetchMock).toHaveBeenCalledWith(
    'https://api.shippo.com/tracking/42',
    expect.objectContaining({ headers: expect.objectContaining({ Authorization: expect.any(String) }) }),
  )
})

The second assertion is the valuable one: it pins the contract with the external service — the endpoint and the auth header — so a refactor that drops the bearer token fails here instead of in production.

Use MSW when several endpoints or realistic parsing are involved. msw intercepts at the network layer, so every fetch in the code under test behaves like the real thing:

import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

const server = setupServer(
  http.get('https://api.shippo.com/tracking/:id', ({ params }) =>
    HttpResponse.json({ status: 'delivered', id: params.id })),
  http.post('https://billing.internal/charges', () =>
    HttpResponse.json({ ok: true }, { status: 201 })),
)

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

onUnhandledRequest: 'error' is the setting that pays for itself: any URL you forgot to mock fails the test immediately instead of quietly reaching the real network from CI. Handlers can be overridden per test with server.use(...) to force an error branch — a 502 from the payment provider — without touching the shared defaults.

MSW also lets you capture the outgoing request, which turns the external call into a contract assertion instead of a side effect:

let captured: Request | undefined
server.use(http.post('https://billing.internal/charges', async ({ request }) => {
  captured = request
  return HttpResponse.json({ ok: true }, { status: 201 })
}))

// ... invoke the handler, then pin what it sent:
expect(await captured!.json()).toEqual({ amount: 1699, currency: 'EUR' })

If the handler starts sending the amount in cents instead of euros, the test names the field — the same guarantee you get from a fetch stub, without owning the URL list yourself.

Reach for next-test-api-route-handler when the handler needs Next's runtime. Cookies read through next/headers, request.nextUrl, segment config, or a revalidatePath call all require the pipeline that a bare new Request skips:

import { testApiHandler } from 'next-test-api-route-handler'  // must be the first import
import * as handler from '@/app/api/me/route'
import { describe, it, expect } from 'vitest'

describe('GET /api/me', () => {
  it('returns 401 without a session cookie', async () => {
    await testApiHandler({
      appHandler: handler,
      url: '/api/me',
      test: async ({ fetch }) => {
        const res = await fetch()
        expect(res.status).toBe(401)
        await expect(res.json()).resolves.toStrictEqual({ error: 'unauthorized' })
      },
    })
  })
})

It runs your route through Next's own resolver, supplies a fetch bound to the handler, and — important — must be imported before anything else, because it patches Next's internals at load time.

Approach Fakes Reaches Reach for it when
vi.stubGlobal('fetch') one call handler logic only exact arguments matter
msw setupServer every matching URL handler + client code multiple endpoints, real parsing
testApiHandler (NTARH) nothing handler inside Next's pipeline cookies(), nextUrl, revalidation
Playwright page.route browser-side network full app end-to-end layer, last topic of this phase

Mocking the outside world is what keeps this layer deterministic — and determinism is exactly what lets you assert the failure branches as confidently as the happy path, which is the next chapter's job.

Asserting the Contract: Status Codes, Validation Branches, and Error Shapes

A handler with one passing test is a handler with one exposed flank. The value of this layer is that every branch of the HTTP contract becomes a cheap, explicit test — the kind that catches an access-control mistake before it ships.

Write the table first, then the tests:

Scenario Status Body shape
Valid request, authenticated 200 resource JSON
Malformed JSON / failed schema 400 { error: { code, message } }
Missing or expired session 401 same envelope, no resource data
Authenticated but not allowed 403 same envelope
Unknown id 404 { error: 'not_found' }
Wrong HTTP method 405 empty or envelope
Rate limit exceeded 429 envelope + Retry-After header

Then express it once and run it across the rows:

const cases: [string, Request, number, unknown][] = [
  ['valid body', orderRequest({ sku: 'SLATE-1', qty: 2 }), 201, { id: expect.any(String) }],
  ['qty of zero', orderRequest({ sku: 'SLATE-1', qty: 0 }), 400, { error: { code: 'invalid_body' } }],
  ['qty as text', orderRequest({ sku: 'SLATE-1', qty: 'two' }), 400, { error: { code: 'invalid_body' } }],
]

it.each(cases)('POST /api/orders with %s', async (_name, req, status, body) => {
  const res = await POST(req)
  expect(res.status).toBe(status)
  await expect(res.json()).resolves.toEqual(body)
})

it.each keeps the assertion identical across branches, so a divergence — say, 400 becoming 422 — shows up as a diff on one row rather than as an argument someone has to re-litigate.

Three contract details interviewers probe:

  • Error envelopes must be uniform. { error: { code, message } } on every failure means your client has one parser. A handler that returns { message: 'oops' } on one path and { error: '...' } on another forces typeof checks in consuming code — name that trade-off when asked how you design error responses.
  • Never leak internals. A 500 in production should be { error: { code: 'internal' } } with the stack in logs, not in the body. Test it: throw deliberately (mock the database to reject with a raw error) and assert the response carries no stack trace, no SQL, no file paths.
  • The auth check belongs on every method. The classic miss is a GET guarded by auth() and a sibling DELETE on the same path that forgot it. Import the handler module once and fetch each export — GET, PATCH, DELETE — asserting 401/403 before 200. Method coverage is a one-line loop, and it is where broken access control hides.
for (const method of ['GET', 'PATCH', 'DELETE'] as const) {
  it(`${method} rejects anonymous callers`, async () => {
    const res = await handlers[method](new Request('http://localhost/api/orders/42', {
      method,
    }), { params: Promise.resolve({ id: '42' }) })
    expect([401, 403]).toContain(res.status)
  })
}

Also pin the success headers that consumers quietly depend on: content-type: application/json, cache-control: no-store on personalized responses, and Location on a 201. Someone — often a CDN or a mobile client you will never meet — reads them.

Success paths deserve the same rigor as failures: mock the data layer and assert the row the handler asked to insert, not just the response it returned. A POST that returns { id } while writing the wrong currency is a bug the response alone will never show, and opening a real database to check would trade this layer's determinism for nothing.

With request/response logic under regression protection, the same directness applies to the other server-side primitive in Next.js. Testing Server Actions in Isolation drops even the Request object: an action is just an async function waiting for a FormData.

Quiz

1. A handler declares `{ params }: { params: Promise<{ id: string }> }` (Next 15). How must the test invoke it?

Question 1 options

2. Your handler reads a session with `cookies()` from `next/headers`. A direct `GET(new Request(...))` call throws. What is the right fix?

Question 2 options

3. A handler calls a third-party API and the suite intermittently fails with 429s from that vendor. What change makes the tests deterministic?

Question 3 options

4. A route module exports GET (authenticated) and DELETE (not authenticated). What does the strongest version of this test file assert?

Question 4 options

Flashcards

Question

What is the minimal shape of a route-handler unit test, and what must it pass for params?

Answer

Import the method export, call it with `new Request(url, { method, headers, body })` and the segment data as `{ params: Promise.resolve({ id }) }`, then await the Response and assert `res.status`, headers, and `await res.json()`. No server, no port — the handler is a function from Request to Response.

Question

Which handler features break under a bare `new Request`, and what tool covers them?

Answer

`cookies()` and `headers()` from next/headers, `request.nextUrl`, segment config, and revalidatePath/revalidateTag need Next's request-scoped context. next-test-api-route-handler's `testApiHandler` runs the route through Next's resolver (import it first — it patches Next internals) and exposes a bound `fetch` for assertions.

Question

When do you stub global fetch versus stand up an msw setupServer?

Answer

Stub with vi.stubGlobal when one outbound call decides the test and you want to assert its exact URL/headers. Use msw when the code fans out to several endpoints or you want real Request/Response parsing; listen with `onUnhandledRequest: 'error'` so unmocked calls fail instead of escaping to the network.

Question

What status codes should a typical protected route handler's test table contain, and why each?

Answer

200/201 success, 400 for schema/JSON failure, 401 unauthenticated, 403 authenticated but forbidden, 404 unknown id, 405 wrong method, 429 rate limited (with Retry-After), and a 500 path asserted to leak no stack traces. Table-driven `it.each` keeps the assertion identical across rows.

Question

Why does the 401/403 test matter more than the 200 test on a route module?

Answer

Because guards drift per method: a GET that checks auth() beside a DELETE that forgot it is the common broken-access-control bug. Fetching every exported method with an anonymous session and asserting 401/403 before 200 covers the whole surface for the price of a loop.

Revision Notes

Key Takeaways

  • 1. App Router handlers are plain `Request → Response` functions, so tests import the export and call it directly — params arrive as a Promise since Next 15 and must be wrapped with Promise.resolve in tests
  • 2. A bare `new Request` covers status, body, headers, url, and params, but nothing request-scoped: cookies(), nextUrl, and revalidation need next-test-api-route-handler, which must be imported first
  • 3. Mock the outside world to kill network variability: vi.stubGlobal for exact-argument assertions on one call, msw setupServer with onUnhandledRequest:'error' for multi-endpoint handlers
  • 4. Write the contract table before the tests — 200, 400, 401, 403, 404, 405, 429 — and drive it with it.each so one divergent status shows up as a diff
  • 5. Assert every exported method rejects anonymous callers, keep one uniform error envelope, and verify 500 responses never carry stack traces or SQL

Interview Tips

  • • "How do you test an API endpoint without running a server?" — the handler is a function from Request to Response; construct a Request, pass params as a resolved Promise, await the Response, assert status and parsed JSON — then name what that skips (cookies, revalidation) and the tool that covers it
  • • "What does your API test suite cover beyond the happy path?" — walk the status table: 400 validation, 401/403 auth on every method, 404 unknown id, 429 rate limit; mention the uniform error envelope so the client parses one shape
  • • "How do you handle third-party dependencies in tests?" — intercept rather than tolerate: exact-argument fetch stubs for single calls, msw for multi-endpoint flows with onUnhandledRequest:'error'; note that retries and timeouts hide the problem instead of removing it
  • • "Give an example of an API bug your tests would have caught" — a sibling method missing the auth check; your answer is the loop over exported methods asserting 401/403 before any 200, which is broken access control caught at commit time

Cheat Sheet

Testing Route Handlers — Cheat Sheet

Direct invocation (no server)

import { GET, POST } from '@/app/api/orders/[id]/route'

const res = await GET(new Request('http://localhost/api/orders/42'), {
  params: Promise.resolve({ id: '42' }),   // Next 15: segment data is a Promise
})
expect(res.status).toBe(200)
await expect(res.json()).resolves.toMatchObject({ id: '42' })

// POST with a body
new Request('http://localhost/api/orders', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ sku: 'SLATE-1', qty: 2 }),
})

Direct call covers vs needs Next's pipeline

Feature Direct test Needs testApiHandler
status / JSON body / headers / url / params ✓
cookies(), headers() from next/headers, nextUrl ✓
revalidatePath / revalidateTag ✓
import { testApiHandler } from 'next-test-api-route-handler'  // FIRST import
await testApiHandler({ appHandler: handler, url: '/api/me',
  test: async ({ fetch }) => { const res = await fetch(); expect(res.status).toBe(401) } })

Network isolation

Tool Fakes Use when
vi.stubGlobal('fetch', vi.fn()) one call assert exact URL/headers
msw setupServer + onUnhandledRequest: 'error' all matching URLs multi-endpoint, real parsing
Playwright page.route browser network E2E layer only

Contract table: 200/201 ok · 400 bad body · 401 no session · 403 forbidden · 404 unknown id · 405 no export · 429 + Retry-After · 500 with no stack traces in the body. Drive rows with it.each, loop over every exported method for the anonymous 401/403 check.

Continue Learning