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/stripeExport functions named after HTTP methods:
// app/api/users/route.tsimport { 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=2export 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.tsexport 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.tsexport 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.tsimport { 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.tsimport { 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.tsexport 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); });}// Usageexport 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.tsimport { NextRequest, NextResponse } from 'next/server';import Stripe from 'stripe';import { z } from 'zod';import { getLogger } from '@kit/shared/logger';// Validate environment variables at startupconst 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.tsexport 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.tsimport 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.tsconst 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 handlerexport 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 hourexport 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!}// Correctexport 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 dataconst { email } = await request.json();await db.users.create({ data: { email } });// Correct - validate firstconst 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 detailsreturn NextResponse.json({ error: error.message }, { status: 500 });// Correct - generic message, log details server-sideconsole.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 Case | Recommendation |
|---|---|
| Form submissions from your app | Server Actions |
| Database mutations from components | Server Actions |
| Public API for mobile apps | Route Handlers |
| Webhook endpoints | Route Handlers |
| Third-party integrations | Route Handlers |
| Proxy to external services | Route 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?
Why do I need to await params in Next.js 15+?
How do I handle CORS in Route Handlers?
Are GET Route Handlers cached by default?
How do I validate webhook signatures?
Can I use Route Handlers with the Edge runtime?
How do I test Route Handlers?
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.