Next.js API Routes Patterns
Apply common API patterns in Next.js route handlers: validation, auth, CORS, errors, and webhooks.
TL;DR
- Validate every request body before you use it, for example with
zod. - Check the session inside each
route.tshandler before returning private data. - Return consistent JSON errors with the right
statuscode.
Validation
z.object()Describe the shape of a valid request body once with a schema.
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(1).max(100),
body: z.string(),
});safeParse()Check the body against the schema and return a 400 when it fails.
const json = await request.json();
const parsed = PostSchema.safeParse(json);
if (!parsed.success) {
return Response.json(
{ error: parsed.error.issues },
{ status: 400 }
);
}Bad JSONCatch a malformed body so the handler returns a 400 instead of crashing.
let json: unknown;
try {
json = await request.json();
} catch {
return Response.json(
{ error: 'Invalid JSON' },
{ status: 400 }
);
}Authentication
Session CheckRead the session first and return a 401 when nobody is signed in.
export async function GET() {
const user = await getSession();
if (!user) {
return Response.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
return Response.json(await getOrders(user.id));
}Bearer TokenCompare the Authorization header with a secret for machine-to-machine calls.
const auth = request.headers.get('authorization');
const token = auth?.replace('Bearer ', '');
if (token !== process.env.API_TOKEN) {
return new Response('Unauthorized', { status: 401 });
}Role CheckReturn a 403 when the user is signed in but lacks permission.
if (user.role !== 'admin') {
return Response.json(
{ error: 'Forbidden' },
{ status: 403 }
);
}CORS and Errors
CORS HeadersList the origin, methods, and headers that other sites may use.
const cors = {
'Access-Control-Allow-Origin': 'https://example.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};OPTIONSAnswer the browser's preflight request with the CORS headers and no body.
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: cors,
});
}Error HelperBuild errors in one place so every endpoint returns the same shape.
function fail(message: string, status = 400) {
return Response.json({ error: message }, { status });
}
return fail('Post not found', 404);try / catchLog the real error on the server and return a generic 500 message.
try {
return Response.json(await getReport());
} catch (err) {
console.error(err);
return fail('Something went wrong', 500);
}Webhooks and Streaming
request.text()Read the raw body of a webhook so you can verify its signature.
export async function POST(request: Request) {
const raw = await request.text();
const sig = request.headers.get('x-signature');
if (!verify(raw, sig)) {
return new Response('Bad sig', { status: 400 });
}
const event = JSON.parse(raw);
return Response.json({ received: true });
}createHmac()Compare a hash of the body with the signature header from the provider.
import { createHmac, timingSafeEqual } from 'crypto';
function verify(raw: string, sig: string | null) {
const hash = createHmac('sha256', SECRET)
.update(raw)
.digest('hex');
if (!sig || sig.length !== hash.length) return false;
return timingSafeEqual(
Buffer.from(hash),
Buffer.from(sig)
);
}ReadableStreamSend a response in chunks, which suits long-running or AI-generated output.
export async function GET() {
const enc = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(enc.encode('Hello, '));
controller.enqueue(enc.encode('world'));
controller.close();
},
});
return new Response(stream);
}Tips
- Wrap shared logic such as auth checks in a small helper function, so every
route.tshandler stays short and consistent. - Read webhook bodies with
request.text(), because signature checks need the exact raw bytes that were sent.
Warnings
- Setting
Access-Control-Allow-Originto*lets any site call your API, so list the exact origins you trust. - Relying only on
proxy.tsfor auth is unsafe, so verify the user again inside each handler that returns data.
In Practice
Combine an auth check, schema validation, and consistent errors in one POST handler.
- The fail helper keeps every error response in the same JSON shape.
- The session check runs first, so unknown callers stop before any work happens.
- safeParse validates the body without throwing on bad input.
- Only validated data reaches the database call.
// app/api/posts/route.ts
import { z } from 'zod';
import { getSession } from '@/lib/auth';
import { createPost } from '@/lib/posts';
const Schema = z.object({ title: z.string().min(1) });
function fail(message: string, status: number) {
return Response.json({ error: message }, { status });
}
export async function POST(request: Request) {
const user = await getSession();
if (!user) return fail('Unauthorized', 401);
const body = await request.json().catch(() => null);
const parsed = Schema.safeParse(body);
if (!parsed.success) return fail('Invalid body', 400);
const post = await createPost(user.id, parsed.data);
return Response.json(post, { status: 201 });
}FAQ
Add the Access-Control-Allow-Origin header to your responses. Also export an OPTIONS handler that returns the allowed methods and headers, because browsers send that request first.
Read the session or token at the top of the handler. Return a 401 response if it is missing or invalid. Do this in every handler that returns private data.
Parse the body with a schema library such as zod. Use safeParse() and return a 400 response with the error details when validation fails.
Create a POST handler, read the raw body with request.text(), and verify the signature header from the provider. Process the event only after the signature matches.