Next.js Route Handlers: The Complete Guide

Learn to build secure, production-ready API endpoints with Next.js Route Handlers. Covers validation, authentication, webhooks, error handling, and logging patterns.

Route Handlers let you create API endpoints in Next.js using standard Web Request and Response APIs. Define a route.ts file, export HTTP method handlers, and you have a working endpoint.

Route Handlers are the App Router's way to create API endpoints. They export HTTP method functions (GET, POST, etc.) from route.ts files and use the Web Request and Response APIs, not Express-style req/res objects.

Use Route Handlers when you need: public APIs for external clients, webhook endpoints, proxy/BFF patterns, or explicit HTTP method control. For internal mutations called from React components, Server Actions are simpler.

Tested with Next.js 16.1, React 19, and TypeScript 5.7 in January 2026.

Route Handler Basics

Route Handlers live in your app directory using route.ts files:

app/
├── api/
│ ├── users/
│ │ └── route.ts # /api/users
│ ├── posts/
│ │ ├── route.ts # /api/posts
│ │ └── [id]/
│ │ └── route.ts # /api/posts/123
│ └── webhooks/
│ └── stripe/
│ └── route.ts # /api/webhooks/stripe

Export functions named after HTTP methods:

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// Using your ORM (Prisma, Drizzle, Supabase, etc.)
const users = await db.users.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await db.users.create(body);
return NextResponse.json(user, { status: 201 });
}

Supported methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Next.js auto-implements OPTIONS if you don't define it.

Verify your setup: Run pnpm dev and test with curl:

curl http://localhost:3000/api/users
# Expected: JSON array of users or empty array []

Working with Requests

Query Parameters

// GET /api/search?query=hello&page=2
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('query'); // "hello"
const page = searchParams.get('page'); // "2"
const tags = searchParams.getAll('tag'); // ["news", "tech"] for ?tag=news&tag=tech
return NextResponse.json({ query, page, tags });
}

Dynamic Route Parameters

In Next.js 15+, params is a Promise that must be awaited:

// app/api/posts/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const post = await db.posts.findUnique({ where: { id } });
if (!post) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(post);
}

For multiple segments:

// app/api/orgs/[orgId]/projects/[projectId]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ orgId: string; projectId: string }> }
) {
const { orgId, projectId } = await params;
// ...
}

Request Body

export async function POST(request: NextRequest) {
const contentType = request.headers.get('content-type');
// JSON
if (contentType?.includes('application/json')) {
const data = await request.json();
return NextResponse.json({ received: data });
}
// Form data
if (contentType?.includes('multipart/form-data')) {
const formData = await request.formData();
const name = formData.get('name');
const file = formData.get('file') as File;
return NextResponse.json({ name, fileName: file?.name });
}
// Raw text
const text = await request.text();
return NextResponse.json({ text });
}

Headers and Cookies

import { cookies, headers } from 'next/headers';
export async function GET(request: NextRequest) {
// Read headers
const headersList = await headers();
const userAgent = headersList.get('user-agent');
const auth = headersList.get('authorization');
// Read cookies
const cookieStore = await cookies();
const sessionId = cookieStore.get('session')?.value;
return NextResponse.json({ userAgent, hasAuth: !!auth, hasSession: !!sessionId });
}
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
// Set a cookie
cookieStore.set('preference', 'dark', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
});
return NextResponse.json({ success: true });
}

Input Validation with Zod

Never trust client input. Validate everything:

import { z } from 'zod';
import { NextRequest, NextResponse } from 'next/server';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
role: z.enum(['user', 'admin']).default('user'),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Validation failed', details: result.error.flatten() },
{ status: 400 }
);
}
const user = await db.users.create({ data: result.data });
return NextResponse.json(user, { status: 201 });
}

For query parameters:

const SearchSchema = z.object({
query: z.string().min(1),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams);
const result = SearchSchema.safeParse(params);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid parameters', details: result.error.flatten() },
{ status: 400 }
);
}
const { query, page, limit } = result.data;
// ...
}

Authentication Patterns

Reusable Auth Wrapper

Create a wrapper function to protect routes:

// lib/api/with-auth.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
type AuthenticatedHandler = (
request: NextRequest,
context: { params: Promise<Record<string, string>>; user: User }
) => Promise<Response>;
export function withAuth(handler: AuthenticatedHandler) {
return async (
request: NextRequest,
context: { params: Promise<Record<string, string>> }
) => {
const session = await getSession();
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
return handler(request, { ...context, user: session.user });
};
}

Use it in your routes:

// app/api/profile/route.ts
import { withAuth } from '@/lib/api/with-auth';
export const GET = withAuth(async (request, { user }) => {
const profile = await db.profiles.findUnique({
where: { userId: user.id },
});
return NextResponse.json(profile);
});
export const PUT = withAuth(async (request, { user }) => {
const body = await request.json();
const profile = await db.profiles.update({
where: { userId: user.id },
data: body,
});
return NextResponse.json(profile);
});

Role-Based Authorization

Extend the wrapper for role checks:

// lib/api/with-role.ts
export function withRole(roles: string[], handler: AuthenticatedHandler) {
return withAuth(async (request, context) => {
if (!roles.includes(context.user.role)) {
return NextResponse.json(
{ error: 'Forbidden' },
{ status: 403 }
);
}
return handler(request, context);
});
}
// Usage
export const DELETE = withRole(['admin'], async (request, { params }) => {
const { id } = await params;
await db.users.delete({ where: { id } });
return NextResponse.json({ success: true });
});

Handling Webhooks

Webhooks require raw body access for signature verification. Here's the pattern we use in MakerKit for Stripe webhooks:

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
// Validate environment variables at startup
const webhookSecret = z
.string({ required_error: 'STRIPE_WEBHOOKS_SECRET is required' })
.min(1)
.parse(process.env.STRIPE_WEBHOOKS_SECRET);
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const logger = await getLogger();
const body = await request.text();
const signature = request.headers.get('stripe-signature');
logger.info('Received Stripe webhook event');
if (!signature) {
logger.error('Missing stripe-signature header');
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
logger.error({ err }, 'Webhook signature verification failed');
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
logger.info({ type: event.type }, 'Processing webhook event');
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
default:
logger.info({ type: event.type }, 'Unhandled event type');
}
return NextResponse.json({ received: true });
}

Always verify webhook signatures. Skipping verification lets attackers forge events and trigger unintended actions in your system.

Error Handling

Return meaningful errors, not generic "Internal Server Error":

// lib/api/errors.ts
export class ApiError extends Error {
constructor(
message: string,
public statusCode: number = 500,
public code?: string
) {
super(message);
this.name = 'ApiError';
}
}
export class NotFoundError extends ApiError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class ValidationError extends ApiError {
constructor(message: string, public details?: unknown) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export function handleApiError(error: unknown) {
console.error('API Error:', error);
if (error instanceof ApiError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.statusCode }
);
}
// Don't expose internal errors to clients
return NextResponse.json(
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
{ status: 500 }
);
}

Use in your handlers:

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const post = await db.posts.findUnique({ where: { id } });
if (!post) {
throw new NotFoundError('Post');
}
return NextResponse.json(post);
} catch (error) {
return handleApiError(error);
}
}

Structured Logging

Good logging saves hours of debugging. MakerKit uses @kit/shared/logger (built on Pino):

import { getLogger } from '@kit/shared/logger';
export async function POST(request: NextRequest) {
const logger = await getLogger();
const startTime = performance.now();
try {
logger.info({ url: request.url }, 'Processing request');
const body = await request.json();
const user = await db.users.create({ data: body });
logger.info(
{ userId: user.id, duration: Math.round(performance.now() - startTime) },
'User created'
);
return NextResponse.json(user, { status: 201 });
} catch (error) {
logger.error(
{ error, duration: Math.round(performance.now() - startTime) },
'Request failed'
);
return handleApiError(error);
}
}

For custom setups without MakerKit:

// lib/logger.ts
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
...(process.env.NODE_ENV === 'development' && {
transport: {
target: 'pino-pretty',
options: { colorize: true },
},
}),
});

CORS Configuration

For APIs consumed by external clients:

// lib/api/cors.ts
const allowedOrigins = [
'https://yourdomain.com',
'https://app.yourdomain.com',
];
export function getCorsHeaders(origin: string | null) {
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
if (origin && allowedOrigins.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
}
return headers;
}
// In your route handler
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get('origin');
return new Response(null, {
status: 204,
headers: getCorsHeaders(origin),
});
}
export async function GET(request: NextRequest) {
const origin = request.headers.get('origin');
const data = await fetchData();
return NextResponse.json(data, {
headers: getCorsHeaders(origin),
});
}

For simpler cases, use Next.js middleware to apply CORS headers globally.

Response Streaming

For AI responses or large datasets:

export async function POST(request: NextRequest) {
const { prompt } = await request.json();
const stream = new ReadableStream({
async start(controller) {
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
stream: true,
});
for await (const chunk of response) {
const text = chunk.choices[0]?.delta?.content || '';
controller.enqueue(new TextEncoder().encode(text));
}
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}

Caching and Revalidation

Control caching behavior with segment config:

// Static response, revalidate every hour
export const revalidate = 3600;
export async function GET() {
const data = await fetchPublicData();
return NextResponse.json(data);
}

For dynamic responses:

export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
// Always fresh data
const data = await fetchRealtimeData();
return NextResponse.json(data);
}

In Next.js 15 and later, GET handlers default to dynamic (uncached). Set export const dynamic = 'force-static' if you want static behavior.

Common Mistakes to Avoid

1. Forgetting to await params (Bug)

// Wrong - params is a Promise in Next.js 15+
export async function GET(request: NextRequest, { params }) {
const id = params.id; // undefined!
}
// Correct
export async function GET(request: NextRequest, { params }) {
const { id } = await params;
}

Forgetting to await params silently returns undefined, causing subtle bugs that only appear with specific route patterns.

2. Not validating input (Security Risk)

// Wrong - trusting client data
const { email } = await request.json();
await db.users.create({ data: { email } });
// Correct - validate first
const result = EmailSchema.safeParse(await request.json());
if (!result.success) return NextResponse.json({ error: 'Invalid email' }, { status: 400 });

3. Leaking internal errors (Security Risk)

// Wrong - exposes implementation details
return NextResponse.json({ error: error.message }, { status: 500 });
// Correct - generic message, log details server-side
console.error('Database error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });

4. Missing webhook signature verification (Security Risk)

Always verify signatures from Stripe, GitHub, Twilio, etc. Skipping verification lets attackers forge events.

5. Using Route Handlers for internal mutations (Code Smell)

If you're only calling an endpoint from your own React components, use Server Actions instead. They're simpler and provide better type safety.

Route Handlers vs Server Actions

Use CaseRecommendation
Form submissions from your appServer Actions
Database mutations from componentsServer Actions
Public API for mobile appsRoute Handlers
Webhook endpointsRoute Handlers
Third-party integrationsRoute Handlers
Proxy to external servicesRoute Handlers

If unsure: Start with Server Actions for any internal mutation. Refactor to Route Handlers only when you need explicit HTTP semantics (custom status codes, streaming responses, CORS) or when external clients need to call your API.

For a detailed comparison, see Server Actions vs Route Handlers.

Frequently Asked Questions

When should I use Route Handlers vs Server Actions?
Use Route Handlers for public APIs consumed by external clients, webhooks, and explicit HTTP control. Use Server Actions for internal mutations from React components since they're simpler and provide automatic type safety.
Why do I need to await params in Next.js 15+?
Next.js 15 changed params to be a Promise for better streaming support. You must await params before accessing dynamic route values. A codemod is available to help upgrade existing code.
How do I handle CORS in Route Handlers?
Export an OPTIONS handler that returns CORS headers, and include the same headers in your other method responses. For app-wide CORS, use Next.js middleware instead.
Are GET Route Handlers cached by default?
In Next.js 15 and later, GET handlers default to dynamic (uncached). To enable caching, export revalidate with a time in seconds, or set dynamic to 'force-static'.
How do I validate webhook signatures?
Read the raw body with request.text(), get the signature header, and use the provider's SDK to verify. For Stripe, use stripe.webhooks.constructEvent(body, signature, secret).
Can I use Route Handlers with the Edge runtime?
Yes, export runtime = 'edge' in your route file. Edge handlers run closer to users but have limited Node.js API access. Use them for latency-sensitive endpoints.
How do I test Route Handlers?
Create a test file, import your handler function, and call it with a mock NextRequest. For integration tests, use a test server that spins up your Next.js app. Libraries like msw can mock external API calls.

Next Steps

MakerKit uses Route Handlers for webhooks (Stripe, Lemon Squeezy) and external integrations, while internal mutations use Server Actions. This hybrid approach keeps the codebase simple while supporting necessary use cases.

For more on securing your Next.js application, see our security guide. To understand when Server Actions make more sense, read Server Actions vs Route Handlers or dive into our complete Server Actions guide.