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 forcestypeofchecks in consuming code — name that trade-off when asked how you design error responses. - Never leak internals. A
500in 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
GETguarded byauth()and a siblingDELETEon 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?
2. Your handler reads a session with `cookies()` from `next/headers`. A direct `GET(new Request(...))` call throws. What is the right fix?
3. A handler calls a third-party API and the suite intermittently fails with 429s from that vendor. What change makes the tests deterministic?
4. A route module exports GET (authenticated) and DELETE (not authenticated). What does the strongest version of this test file assert?
Flashcards
Question
What is the minimal shape of a route-handler unit test, and what must it pass for params?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.