In the Next.js 15 App Router architecture, API endpoints are implemented as Route Handlers. Unlike legacy Pages Router API Routes (req: NextApiRequest, res: NextApiResponse), modern handlers fully adhere to the WHATWG Fetch Standard.
This transition to standardized Web APIs brings significant architectural advantages to testing:
export async function GET(request: NextRequest, context: RouteContext): Promise<Response>. It can be directly executed inside Vitest tests in a Node.js runtime by supplying synthetic Request or NextRequest instances without spinning up a physical HTTP listener.ReadableStream.context.params are asynchronous (Promise<{ [key: string]: string | string[] }>), unifying async I/O handling across the framework.Integration testing Route Handlers enables microsecond-level verification of OpenAPI contracts, HTTP response codes, CORS policies, session cookie management, and streaming pipeline integrity.
In the testing mental model, a Route Handler is viewed as a pure functional transformer converting a standard Web Request into a Web Response:
+-------------------------------------------------------------------------------+
| Vitest Test Runner (Node.js Environment) |
+-------------------------------------------------------------------------------+
│
├─► 1. Construct new NextRequest('https://api.domain/endpoint?filter=active')
├─► 2. Append Headers: new Headers({ 'Content-Type': 'application/json' })
├─► 3. Supply dynamic params context: { params: Promise.resolve(...) }
│
▼
+-------------------------------------------------------------------------------+
| Route Handler (app/api/.../route.ts) |
+-------------------------------------------------------------------------------+
│
├─► Bearer Token / Session Authentication
├─► Validate Request Body: await req.json() (Zod Schema)
├─► Delegate to Data Access Layer (DAL / Drizzle)
│
▼
+-------------------------------------------------------------------------------+
| Result: Response / NextResponse |
+-------------------------------------------------------------------------------+
│
├─► expect(res.status).toBe(200 | 201 | 400 | 401 | 500)
├─► const body = await res.json()
├─► expect(res.headers.get('set-cookie')).toBeDefined()
└─► const reader = res.body.getReader() (for ReadableStream / SSE)OPTIONS preflight requests correctly set Access-Control-Allow-Origin and Access-Control-Allow-Headers for public or partner APIs.Secure, SameSite=Strict, and Path=/ flags.ReadableStream.Here is a Route Handler delivering project analytics for dynamic routes app/api/projects/[id]/analytics/route.ts. In Next.js 15, context.params returns a Promise.
import { NextRequest, NextResponse } from 'next/server';
import { getProjectAnalytics } from '@/dal/analytics';
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function GET(request: NextRequest, context: RouteContext): Promise<Response> {
// In Next.js 15, params is asynchronous
const { id } = await context.params;
if (!id || id === 'invalid') {
return NextResponse.json({ error: 'Invalid project identifier' }, { status: 400 });
}
const { searchParams } = request.nextUrl;
const period = searchParams.get('period') || '30d';
const analytics = await getProjectAnalytics(id, period);
if (!analytics) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
return NextResponse.json(
{ data: analytics, meta: { period, generatedAt: new Date().toISOString() } },
{
status: 200,
headers: {
'Cache-Control': 'private, no-cache, no-store',
},
}
);
}import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from './route';
import * as dal from '@/dal/analytics';
vi.mock('@/dal/analytics', () => ({
getProjectAnalytics: vi.fn(),
}));
describe('GET /api/projects/[id]/analytics', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 200 OK and analytics data for valid parameters', async () => {
vi.mocked(dal.getProjectAnalytics).mockResolvedValue({
views: 12500,
conversions: 450,
});
// Construct NextRequest with query parameters
const request = new NextRequest('https://agenticlab.dev/api/projects/proj-101/analytics?period=7d');
// Construct asynchronous context for Next.js 15
const context = {
params: Promise.resolve({ id: 'proj-101' }),
};
const response = await GET(request, context);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('private, no-cache, no-store');
const json = await response.json();
expect(json.data).toEqual({ views: 12500, conversions: 450 });
expect(json.meta.period).toBe('7d');
expect(dal.getProjectAnalytics).toHaveBeenCalledWith('proj-101', '7d');
});
it('returns 404 Not Found when project does not exist', async () => {
vi.mocked(dal.getProjectAnalytics).mockResolvedValue(null);
const request = new NextRequest('https://agenticlab.dev/api/projects/not-found/analytics');
const context = {
params: Promise.resolve({ id: 'not-found' }),
};
const response = await GET(request, context);
expect(response.status).toBe(404);
const json = await response.json();
expect(json.error).toBe('Project not found');
});
it('returns 400 Bad Request on invalid identifier', async () => {
const request = new NextRequest('https://agenticlab.dev/api/projects/invalid/analytics');
const context = {
params: Promise.resolve({ id: 'invalid' }),
};
const response = await GET(request, context);
expect(response.status).toBe(400);
expect(dal.getProjectAnalytics).not.toHaveBeenCalled();
});
});This authentication endpoint accepts user credentials, verifies the password, and returns a response setting a secure session cookie.
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyCredentials } from '@/dal/auth';
const LoginSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export async function POST(request: NextRequest): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Malformed JSON payload' }, { status: 400 });
}
const parsed = LoginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation error', details: parsed.error.flatten().fieldErrors },
{ status: 422 }
);
}
const session = await verifyCredentials(parsed.data.email, parsed.data.password);
if (!session) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
}
const response = NextResponse.json(
{ user: { id: session.userId, email: parsed.data.email } },
{ status: 200 }
);
// Set secure HttpOnly cookie
response.cookies.set({
name: 'auth_session',
value: session.token,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return response;
}import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { POST } from './route';
import * as authDal from '@/dal/auth';
vi.mock('@/dal/auth');
describe('POST /api/auth/login', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 200 OK and sets secure session cookie on valid credentials', async () => {
vi.mocked(authDal.verifyCredentials).mockResolvedValue({
userId: 'usr-900',
token: 'jwt.enterprise.token',
});
const payload = {
email: '[email protected]',
password: 'StrongSecretPassword123!',
};
const request = new NextRequest('https://agenticlab.dev/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const response = await POST(request);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.user.id).toBe('usr-900');
// Assert Set-Cookie header attributes
const setCookie = response.headers.get('set-cookie');
expect(setCookie).toBeDefined();
expect(setCookie).toContain('auth_session=jwt.enterprise.token');
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('SameSite=Strict');
});
it('returns 422 Unprocessable Entity on invalid email', async () => {
const request = new NextRequest('https://agenticlab.dev/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'not-an-email', password: '123' }),
});
const response = await POST(request);
expect(response.status).toBe(422);
const json = await response.json();
expect(json.error).toBe('Validation error');
expect(json.details.email).toBeDefined();
expect(authDal.verifyCredentials).not.toHaveBeenCalled();
});
it('returns 401 Unauthorized on invalid credentials', async () => {
vi.mocked(authDal.verifyCredentials).mockResolvedValue(null);
const request = new NextRequest('https://agenticlab.dev/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', password: 'WrongPassword123' }),
});
const response = await POST(request);
expect(response.status).toBe(401);
const json = await response.json();
expect(json.error).toBe('Invalid email or password');
});
});In modern architectures, Route Handlers frequently emit real-time stream data (LLM token generation or Server-Sent Events). To test streams, read chunks through a native ReadableStreamDefaultReader and decode bytes via TextDecoder.
import { describe, it, expect } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from './route';
describe('GET /api/stream/events (ReadableStream SSE)', () => {
it('delivers streaming response with 200 status and SSE chunks', async () => {
const request = new NextRequest('https://agenticlab.dev/api/stream/events');
const response = await GET(request);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toContain('text/event-stream');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
expect(response.body).toBeDefined();
// Read chunks from ReadableStream
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
let isDone = false;
while (!isDone) {
const { value, done } = await reader.read();
if (done) {
isDone = true;
} else if (value) {
chunks.push(decoder.decode(value, { stream: true }));
}
}
const fullContent = chunks.join('');
expect(fullContent).toContain('data: {"event":"connected"}');
expect(fullContent).toContain('data: {"event":"ready"}');
});
});Unlike legacy req.body, the body of a native Request is a one-time ReadableStream. Attempting to consume it multiple times (e.g., inside a logging utility and subsequently within the route handler) throws TypeError: Body has already been consumed or is disturbed.
// ❌ FAILS: Multiple body consumption attempts
const clone = request; // Does not clone the underlying byte stream!
await request.json();
await clone.json(); // Throws: body already disturbed
// ✅ SECURE: Call request.clone() BEFORE the first consumption pass
const requestClone = request.clone();
const data1 = await request.json();
const data2 = await requestClone.json();In Next.js 15, the params property on the second handler argument (context) is a Promise<{ ... }>. Accessing context.params.id directly resolves to undefined because properties cannot be read synchronously off an unresolved Promise.
// ❌ WRONG: params is a Promise in Next.js 15
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const id = (params as unknown as Record<string, string>).id; // undefined!
}
// ✅ CORRECT: Await context.params
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
}If you append CORS response headers manually without exporting a dedicated export async function OPTIONS() preflight handler, browser preflight requests will receive 405 Method Not Allowed, blocking client cross-origin traffic. Always test preflight behavior.
If a server stream implementation fails to call controller.close(), a reader loop while (!done) { await reader.read() } will block indefinitely and trigger a Vitest test timeout. Always ensure mock streams terminate reliably or simulate cancellation via reader.cancel().
Instantiate synthetic request with full URL, HTTP method, headers, and body payload.
Wrap dynamic segment parameters into Promise.resolve(params) for Next.js 15 compatibility.
Isolate database calls and external downstream services using vi.mock().
Call the exported GET/POST/DELETE function directly as an asynchronous function.
Verify res.status, Content-Type headers, CORS directives, and caching attributes.
Parse payload via await res.json() or stream chunks via reader.read().