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.ts
import { 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.ts
import { 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.ts
import { 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.ts
import { 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.ts
import { 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.ts
export 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.ts
import { 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.ts
import { 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

ScenarioUse
Form submission from your appServer Action
Button click triggers mutationServer Action
Webhook from Stripe/GitHubRoute Handler
External service needs your APIRoute Handler
Need custom status codesRoute Handler
Need streaming responseRoute Handler
Need to set specific headersRoute Handler

Common Mistakes

Forgetting to Verify Webhook Signatures

// WRONG: Trusting webhook data without verification
export async function POST(request: Request) {
const event = await request.json();
await processEvent(event); // Anyone can call this!
}
// RIGHT: Verify signature before processing
export 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 operations
export async function POST(request: Request) {
// Verify signature first!
const supabase = getSupabaseServerAdminClient();
await supabase.from('subscriptions').update({ ... });
}

Next Steps