How do I test Next.js App Router API routes?
Use next-test-api-route-handler (NTARH). It runs your route.ts exports through Next.js's own resolver so you get real Request / Response objects, cookies(), headers(), and App Router segment config without starting next start.
Next.js does not document a first-party way to unit-test Route Handlers. Official testing guides cover components. Route Handlers depend on patched fetch, NextRequest, optional segment config (runtime, dynamic, preferredRegion), and sometimes the Edge runtime. Calling GET(new Request(...)) yourself skips those internals. node-mocks-http targets the Pages Router req/res pair and does not construct a Web Request.
NTARH is test-framework agnostic. Vitest is the default in new Next.js apps and is what this page uses. Jest still works if the repo already has next/jest. Import NTARH first in the file. That requirement is current: the package patches Next internals at load time, and a later import will resolve the unpatched module.
Write one test per status you care about: unauthenticated 401, authenticated 200, authenticated-but-forbidden 403, and the verb you forgot (POST or DELETE on a GET-only check). Broken access control is still the failure these tests catch. A handler that checks auth() on GET and not on DELETE is the usual miss.
How do I set up next-test-api-route-handler with Vitest?
npm i -D vitest next-test-api-route-handlerimport { defineConfig } from "vitest/config";import path from "node:path";
export default defineConfig({ test: { environment: "node", setupFiles: ["./vitest.setup.ts"], }, resolve: { alias: { "@": path.resolve(__dirname, ".") }, },});export async function GET() { return Response.json({ hello: true }, { status: 200 });}import { testApiHandler } from "next-test-api-route-handler"; // must be firstimport { describe, it, expect } from "vitest";import * as appHandler from "./route";
describe("GET /api/hello", () => { it("returns 200", async () => { await testApiHandler({ appHandler, test: async ({ fetch }) => { const response = await fetch({ method: "GET" }); expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ hello: true }); }, }); });});Run with npx vitest run. The Jest equivalent is the same testApiHandler call inside it(), with testEnvironment: "node" in jest.config.ts via next/jest. Prefer Vitest unless the repo already has a Jest harness.
NTARH also emulates the Pages Router and the Edge runtime. Pass params for dynamic segments. Use requestPatcher when you need a header or cookie that fetch() will not let you set cleanly.
import { testApiHandler } from "next-test-api-route-handler"; // must be firstimport { expect, it } from "vitest";import * as appHandler from "./route";
it("reads the dynamic id", async () => { await testApiHandler({ appHandler, params: { id: "item_123" }, requestPatcher(request) { request.headers.set("x-request-id", "test"); }, test: async ({ fetch }) => { const response = await fetch({ method: "GET" }); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ id: "item_123" }); }, });});Keep Route Handler tests in the node environment. jsdom is for components. A file that imports both a handler and a client component should be split so the handler file never loads a DOM.
How do I mock Auth.js v5 sessions?
Auth.js v5 (the next-auth package, formerly NextAuth) replaced getServerSession with a single auth() helper exported from your root auth.ts. Mock that module, not next-auth/next.
import { auth } from "@/auth";
export async function GET() { const session = await auth(); if (!session?.user) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } return Response.json({ hello: true }, { status: 200 });}import { testApiHandler } from "next-test-api-route-handler"; // must be firstimport { afterEach, describe, expect, it, vi } from "vitest";import type { Session } from "next-auth";import * as appHandler from "./route";import { auth } from "@/auth";
vi.mock("@/auth", () => ({ auth: vi.fn(),}));
let mockedSession: Session | null = null;
afterEach(() => { mockedSession = null; vi.mocked(auth).mockReset();});
describe("GET /api/hello", () => { it("returns 401 when signed out", async () => { vi.mocked(auth).mockResolvedValue(null);
await testApiHandler({ appHandler, test: async ({ fetch }) => { const response = await fetch({ method: "GET" }); expect(response.status).toBe(401); await expect(response.json()).resolves.toStrictEqual({ error: "Unauthorized", }); }, }); });
it("returns 200 when signed in", async () => { mockedSession = { expires: "2099-01-01T00:00:00.000Z", user: { id: "user_1", email: "dev@example.com" }, }; vi.mocked(auth).mockResolvedValue(mockedSession);
await testApiHandler({ appHandler, test: async ({ fetch }) => { const response = await fetch({ method: "GET" }); expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ hello: true }); }, }); });});Add a third test that is signed in as user A and requests user B's resource. That is the broken-access-control case these route tests exist to catch.
it("returns 403 when the session cannot access the object", async () => { vi.mocked(auth).mockResolvedValue({ expires: "2099-01-01T00:00:00.000Z", user: { id: "user_a", email: "a@example.com" }, });
await testApiHandler({ appHandler, params: { userId: "user_b" }, test: async ({ fetch }) => { const response = await fetch({ method: "DELETE" }); expect(response.status).toBe(403); }, });});Clerk and other providers follow the same shape: mock the helper your handler actually calls (currentUser(), auth() from @clerk/nextjs/server, and so on). Do not mock next/headers unless the handler reads cookies itself. If it only calls auth(), that one mock is enough.
v4 code that imported getServerSession from next-auth/next and passed authOptions around should move the config to auth.ts and switch the mock target. Leaving a jest.mock("next-auth/next") in a Vitest file after the migration is a silent pass: the handler no longer calls that module.
How do I test an Arcjet-protected route?
Branch on decision.isDenied() in the handler, then cover both sides in NTARH. For unit tests, mock protect(). For a live rule (bot UA, rate-limit burst), use the functional testing suite against a running server.
import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/next";
export const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: [] }), slidingWindow({ mode: "LIVE", interval: 60, max: 100 }), ],});import { aj } from "@/lib/arcjet";
export async function GET(req: Request) { const decision = await aj.protect(req); if (decision.isDenied()) { const status = decision.reason.isRateLimit() ? 429 : 403; return Response.json({ error: "Denied" }, { status }); } return Response.json({ ok: true });}import { testApiHandler } from "next-test-api-route-handler"; // must be firstimport { describe, expect, it, vi } from "vitest";import * as appHandler from "./route";import { aj } from "@/lib/arcjet";
vi.mock("@/lib/arcjet", () => ({ aj: { protect: vi.fn() },}));
describe("GET /api/secure", () => { it("returns 403 when Arcjet denies a bot", async () => { vi.mocked(aj.protect).mockResolvedValue({ isDenied: () => true, reason: { isRateLimit: () => false }, } as Awaited<ReturnType<typeof aj.protect>>);
await testApiHandler({ appHandler, test: async ({ fetch }) => { const response = await fetch({ method: "GET" }); expect(response.status).toBe(403); await expect(response.json()).resolves.toStrictEqual({ error: "Denied", }); }, }); });
it("returns 200 when Arcjet allows", async () => { vi.mocked(aj.protect).mockResolvedValue({ isDenied: () => false, reason: { isRateLimit: () => false }, } as Awaited<ReturnType<typeof aj.protect>>);
await testApiHandler({ appHandler, test: async ({ fetch }) => { const response = await fetch({ method: "GET" }); expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ ok: true }); }, }); });});The mock proves your handler maps deny to the right status. It does not prove the rule configuration. A User-Agent: curl request against a live detectBot({ allow: [] }) rule is the complementary test. Put that in Newman, k6, or a second Vitest file that starts the app. If you mock protect() and never run a live request, a typo in allow: [] will ship.
Also test POST and DELETE on the same path. NTARH will call whichever export you fetch. A GET that checks auth() and a POST that does not is a common access-control miss.
When protect() needs a real request shape (IP, user agent, body), prefer requestPatcher plus a live Arcjet client in DRY_RUN only for logging. Assertions on deny require LIVE and a running decide path, which is slower and needs ARCJET_KEY. Keep those tests in the functional suite so unit tests stay offline.
How do I test the Edge runtime?
If the route exports export const runtime = "edge", tell NTARH so it uses the Edge resolver. A Node-environment test that passes against an Edge handler can hide node:crypto or fs imports that will fail in production.
await testApiHandler({ appHandler, // NTARH reads runtime from the module export when present. test: async ({ fetch }) => { const response = await fetch({ method: "GET" }); expect(response.status).toBe(200); },});Do not test Server Components or Server Actions with NTARH. It is a Route Handler tool. Components belong in a component runner or Playwright. Actions that call auth() and aj.protect() can use the same mocks in a direct function call, but they will not exercise cookies the way a handler does.
Assert the contract the client depends on: status, JSON shape, and Retry-After or your deny body. Do not assert the full Arcjet decision object in a unit test. That object changes with SDK versions and is not part of your HTTP API.
Frequently asked questions
How do I test Next.js App Router API routes?
Use next-test-api-route-handler. Import it first, pass the route module as appHandler, and call fetch() inside test(). Prefer Vitest with environment node.
What replaced getServerSession?
Auth.js v5 exports auth() from your root auth.ts. Mock @/auth, not next-auth/next. A leftover getServerSession mock after the migration is a silent pass.
Should I use Jest or Vitest?
Vitest is the usual default in new Next.js apps. Jest plus next/jest still works. NTARH is framework-agnostic.
How do I test an Arcjet-protected route?
Mock aj.protect() to return isDenied() true or false and assert 403/429 versus 200. Prove the live rule with Newman or k6 against a running server.
Does NTARH test Server Components?
No. It is a Route Handler tool. Components belong in a component runner or Playwright.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.