API Route Handlers in Next.js
Build API endpoints with Next.js Route Handlers. Covers the enhanceRouteHandler utility, webhook handling, CSRF protection, and when to use Route Handlers vs Server Actions.
Route Handlers create HTTP API endpoints in Next.js by exporting functions named GET, POST, PUT, or DELETE from a route.ts file.
While Server Actions handle most mutations, Route Handlers are essential for webhooks (Stripe, Lemon Squeezy), external API access, streaming responses, and scenarios needing custom HTTP headers or status codes.
MakerKit's enhanceRouteHandler adds authentication and validation. Tested with Next.js 16 (async headers/params).
Use Route Handlers for webhooks, external services calling your API, streaming responses, and public APIs. Use Server Actions for mutations from your own app (forms, button clicks).
When to Use Route Handlers
Use Route Handlers for:
- Webhook endpoints (Stripe, Lemon Squeezy, GitHub, etc.)
- External services calling your API
- Public APIs for third-party consumption
- Streaming responses or Server-Sent Events
- Custom headers, status codes, or response formats
Use Server Actions instead for:
- Form submissions from your own app
- Mutations triggered by user interactions
- Any operation that doesn't need HTTP details
Basic Route Handler
Create a route.ts file in any route segment:
// app/api/health/route.tsimport { NextResponse } from 'next/server';export async function GET() { return NextResponse.json({ status: 'healthy', timestamp: new Date().toISOString(), });}This creates an endpoint at /api/health that responds to GET requests.
HTTP Methods
Export functions named after HTTP methods:
// app/api/tasks/route.tsimport { NextResponse } from 'next/server';export async function GET(request: Request) { // Handle GET /api/tasks}export async function POST(request: Request) { // Handle POST /api/tasks}export async function PUT(request: Request) { // Handle PUT /api/tasks}export async function DELETE(request: Request) { // Handle DELETE /api/tasks}Using enhanceRouteHandler
The enhanceRouteHandler utility adds authentication, validation, and captcha verification:
import { NextResponse } from 'next/server';import { z } from 'zod';import { enhanceRouteHandler } from '@kit/next/routes';import { getSupabaseServerClient } from '@kit/supabase/server-client';const CreateTaskSchema = z.object({ title: z.string().min(1), accountId: z.string().uuid(),});export const POST = enhanceRouteHandler( async ({ body, user, request }) => { // body is validated against the schema // user is the authenticated user // request is the original NextRequest const supabase = getSupabaseServerClient(); const { data, error } = await supabase .from('tasks') .insert({ title: body.title, account_id: body.accountId, created_by: user.id, }) .select() .single(); if (error) { return NextResponse.json( { error: 'Failed to create task' }, { status: 500 } ); } return NextResponse.json({ task: data }, { status: 201 }); }, { schema: CreateTaskSchema, auth: true, // Require authentication (default) });Configuration Options
enhanceRouteHandler(handler, { // Zod schema for request body validation schema: MySchema, // Require authentication (default: true) auth: true, // Require captcha verification (default: false) captcha: false,});Public Endpoints
For public endpoints (no authentication required):
export const GET = enhanceRouteHandler( async ({ request }) => { // user will be undefined const supabase = getSupabaseServerClient(); const { data } = await supabase .from('public_content') .select('*') .limit(10); return NextResponse.json({ content: data }); }, { auth: false });Dynamic Route Parameters
Access route parameters in Route Handlers:
// app/api/tasks/[id]/route.tsimport { NextResponse } from 'next/server';import { enhanceRouteHandler } from '@kit/next/routes';import { getSupabaseServerClient } from '@kit/supabase/server-client';export const GET = enhanceRouteHandler( async ({ user, params }) => { const supabase = getSupabaseServerClient(); const { data, error } = await supabase .from('tasks') .select('*') .eq('id', params.id) .single(); if (error || !data) { return NextResponse.json( { error: 'Task not found' }, { status: 404 } ); } return NextResponse.json({ task: data }); }, { auth: true });export const DELETE = enhanceRouteHandler( async ({ user, params }) => { const supabase = getSupabaseServerClient(); const { error } = await supabase .from('tasks') .delete() .eq('id', params.id) .eq('created_by', user.id); if (error) { return NextResponse.json( { error: 'Failed to delete task' }, { status: 500 } ); } return new Response(null, { status: 204 }); }, { auth: true });Webhook Handling
Webhooks require special handling since they come from external services without user authentication.
Stripe Webhook Example
// app/api/webhooks/stripe/route.tsimport { headers } from 'next/headers';import { NextResponse } from 'next/server';import Stripe from 'stripe';import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;export async function POST(request: Request) { const body = await request.text(); const headersList = await headers(); const signature = headersList.get('stripe-signature'); if (!signature) { return NextResponse.json( { error: 'Missing signature' }, { status: 400 } ); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, signature, webhookSecret); } catch (err) { console.error('Webhook signature verification failed:', err); return NextResponse.json( { error: 'Invalid signature' }, { status: 400 } ); } // Use admin client since webhooks don't have user context const supabase = getSupabaseServerAdminClient(); switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session; await supabase .from('subscriptions') .update({ status: 'active' }) .eq('stripe_customer_id', session.customer); break; } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription; await supabase .from('subscriptions') .update({ status: 'cancelled' }) .eq('stripe_subscription_id', subscription.id); break; } default: console.log(`Unhandled event type: ${event.type}`); } return NextResponse.json({ received: true });}Generic Webhook Pattern
// app/api/webhooks/[provider]/route.tsimport { NextResponse } from 'next/server';import { headers } from 'next/headers';import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';type WebhookHandler = { verifySignature: (body: string, signature: string) => boolean; handleEvent: (event: unknown) => Promise<void>;};const handlers: Record<string, WebhookHandler> = { stripe: { verifySignature: (body, sig) => { /* ... */ }, handleEvent: async (event) => { /* ... */ }, }, github: { verifySignature: (body, sig) => { /* ... */ }, handleEvent: async (event) => { /* ... */ }, },};export async function POST( request: Request, { params }: { params: Promise<{ provider: string }> }) { const { provider } = await params; const handler = handlers[provider]; if (!handler) { return NextResponse.json( { error: 'Unknown provider' }, { status: 404 } ); } const body = await request.text(); const headersList = await headers(); const signature = headersList.get('x-signature') ?? ''; if (!handler.verifySignature(body, signature)) { return NextResponse.json( { error: 'Invalid signature' }, { status: 401 } ); } try { const event = JSON.parse(body); await handler.handleEvent(event); return NextResponse.json({ received: true }); } catch (error) { console.error(`Webhook error (${provider}):`, error); return NextResponse.json( { error: 'Processing failed' }, { status: 500 } ); }}CSRF Protection
Routes outside /api/* are protected against CSRF by default. When calling these routes from the client, include the CSRF token:
'use client';import { useCsrfToken } from '@kit/shared/hooks';export function MyComponent() { const csrfToken = useCsrfToken(); const handleSubmit = async () => { const response = await fetch('/my-route', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken, }, body: JSON.stringify({ data: 'value' }), }); }; return <button onClick={handleSubmit}>Submit</button>;}Routes under /api/* are NOT CSRF-protected by default, as they're intended for external access (webhooks, third-party integrations).
See CSRF Protection for more details.
Streaming Responses
Route Handlers support streaming for real-time data:
// app/api/stream/route.tsexport async function GET() { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { for (let i = 0; i < 10; i++) { const data = JSON.stringify({ count: i, timestamp: Date.now() }); controller.enqueue(encoder.encode(`data: ${data}\n\n`)); await new Promise((resolve) => setTimeout(resolve, 1000)); } controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, });}File Uploads
Handle file uploads with Route Handlers:
// app/api/upload/route.tsimport { NextResponse } from 'next/server';import { enhanceRouteHandler } from '@kit/next/routes';import { getSupabaseServerClient } from '@kit/supabase/server-client';export const POST = enhanceRouteHandler( async ({ request, user }) => { const formData = await request.formData(); const file = formData.get('file') as File; if (!file) { return NextResponse.json( { error: 'No file provided' }, { status: 400 } ); } // Validate file type const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { return NextResponse.json( { error: 'Invalid file type' }, { status: 400 } ); } // Validate file size (5MB max) if (file.size > 5 * 1024 * 1024) { return NextResponse.json( { error: 'File too large' }, { status: 400 } ); } const supabase = getSupabaseServerClient(); const fileName = `${user.id}/${Date.now()}-${file.name}`; const { error } = await supabase.storage .from('uploads') .upload(fileName, file); if (error) { return NextResponse.json( { error: 'Upload failed' }, { status: 500 } ); } const { data: urlData } = supabase.storage .from('uploads') .getPublicUrl(fileName); return NextResponse.json({ url: urlData.publicUrl, }); }, { auth: true });Error Handling
Consistent Error Responses
Create a helper for consistent error responses:
// lib/api-errors.tsimport { NextResponse } from 'next/server';export function apiError( message: string, status: number = 500, details?: Record<string, unknown>) { return NextResponse.json( { error: message, ...details, }, { status } );}export function notFound(resource: string = 'Resource') { return apiError(`${resource} not found`, 404);}export function unauthorized(message: string = 'Unauthorized') { return apiError(message, 401);}export function badRequest(message: string, field?: string) { return apiError(message, 400, field ? { field } : undefined);}Usage:
import { notFound, badRequest } from '@/lib/api-errors';export const GET = enhanceRouteHandler( async ({ params }) => { const task = await getTask(params.id); if (!task) { return notFound('Task'); } return NextResponse.json({ task }); }, { auth: true });Route Handler vs Server Action
| Scenario | Use |
|---|---|
| Form submission from your app | Server Action |
| Button click triggers mutation | Server Action |
| Webhook from Stripe/GitHub | Route Handler |
| External service needs your API | Route Handler |
| Need custom status codes | Route Handler |
| Need streaming response | Route Handler |
| Need to set specific headers | Route Handler |
Common Mistakes
Forgetting to Verify Webhook Signatures
// WRONG: Trusting webhook data without verificationexport async function POST(request: Request) { const event = await request.json(); await processEvent(event); // Anyone can call this!}// RIGHT: Verify signature before processingexport async function POST(request: Request) { const body = await request.text(); const signature = request.headers.get('x-signature'); if (!verifySignature(body, signature)) { return new Response('Invalid signature', { status: 401 }); } const event = JSON.parse(body); await processEvent(event);}Using Wrong Client in Webhooks
// WRONG: Regular client in webhook (no user session)export async function POST(request: Request) { const supabase = getSupabaseServerClient(); // This will fail - no user session for RLS await supabase.from('subscriptions').update({ ... });}// RIGHT: Admin client for webhook operationsexport async function POST(request: Request) { // Verify signature first! const supabase = getSupabaseServerAdminClient(); await supabase.from('subscriptions').update({ ... });}Next Steps
- Server Actions - For mutations from your app
- CSRF Protection - Secure your endpoints
- Captcha Protection - Bot protection