# 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.

*Canonical: https://makerkit.dev/docs/next-supabase-turbo/data-fetching/route-handlers*

---

[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
