Files
myeasycms-v2/docs/data-fetching/route-handlers.mdoc
Giancarlo Buomprisco 7ebff31475 Next.js Supabase V3 (#463)
Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
2026-03-24 13:40:38 +08:00

568 lines
14 KiB
Plaintext

---
status: "published"
title: "API Route Handlers in Next.js"
label: "Route Handlers"
description: "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."
order: 2
---
[Route Handlers](/blog/tutorials/server-actions-vs-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).
{% callout title="When to use Route Handlers" %}
**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).
{% /callout %}
## 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:
```tsx
// 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:
```tsx
// 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:
```tsx
import { NextResponse } from 'next/server';
import * as 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
```tsx
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):
```tsx
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:
```tsx
// 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
```tsx
// 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
```tsx
// 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
CSRF protection is handled natively by Next.js Server Actions. No manual CSRF token management is needed.
Routes under `/api/*` are intended for external access (webhooks, third-party integrations) and do not have CSRF protection. Use authentication checks via `enhanceRouteHandler` with `auth: true` if needed.
## Streaming Responses
Route Handlers support streaming for real-time data:
```tsx
// 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:
```tsx
// 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:
```tsx
// 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:
```tsx
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
```tsx
// 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
```tsx
// 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
- [Server Actions](server-actions) - For mutations from your app
- [CSRF Protection](csrf-protection) - Secure your endpoints
- [Captcha Protection](captcha-protection) - Bot protection