Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 030 / 100

Route Handlers in Next.js for React & Next.js developers

API endpoints inside your Next.js app.

Expona Admin8 min read

When you're building a Next.js application, you'll eventually need to expose HTTP endpoints—whether for webhooks from payment processors, callbacks from third-party services, or APIs consumed by mobile clients. Route Handlers in Next.js let you define API endpoints directly inside your app directory using simple file-based routing, without needing a separate backend framework. In this article, I'll walk you through how they work, when they're the right choice, and the gotchas I've learned over years of building production platforms.

How route.ts Works — Export Functions Named After HTTP Methods

The core idea behind Route Handlers is simple: create a file named route.ts in your app directory, export async functions named after HTTP verbs (GET, POST, PUT, DELETE, etc.), and Next.js automatically wires them up as API endpoints.

Let's say you're building a leads management system for a CRM. You want to expose an endpoint that returns all leads:

tsx
// app/api/leads/route.ts
import { getLeads } from '@/lib/db';

export async function GET(req: Request) {
  const leads = await getLeads();
  return Response.json(leads);
}

That's it. When a client sends a GET request to /api/leads, Next.js invokes this handler and returns the leads as JSON. The route structure mirrors your file system: a file at app/api/leads/route.ts becomes the endpoint GET /api/leads.

You can also handle multiple methods in the same file:

tsx
// app/api/leads/route.ts
import { getLeads, createLead } from '@/lib/db';

export async function GET(req: Request) {
  const leads = await getLeads();
  return Response.json(leads);
}

export async function POST(req: Request) {
  const body = await req.json();
  const lead = await createLead(body);
  return Response.json(lead, { status: 201 });
}

The Request object follows the standard Web API, so you can read headers, query parameters, and the body the same way you would in any JavaScript runtime:

tsx
export async function POST(req: Request) {
  const url = new URL(req.url);
  const source = url.searchParams.get('source'); // query param
  const body = await req.json(); // request body
  const token = req.headers.get('authorization'); // header

  if (!token) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const lead = await createLead({ ...body, source });
  return Response.json(lead, { status: 201 });
}

Route Handlers also receive a context object as a second parameter, giving you access to dynamic route segments:

tsx
// app/api/leads/[id]/route.ts
export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const lead = await getLead(params.id);
  if (!lead) {
    return Response.json({ error: 'Not found' }, { status: 404 });
  }
  return Response.json(lead);
}

When to Use Route Handlers — Webhooks, Third-Party Callbacks, and Public APIs

Route Handlers shine when you need to handle requests from outside your Next.js application. I've relied on them extensively in every production system I've built.

Webhooks from payment providers. When Stripe or Razorpay sends you a webhook notification, you need an HTTP endpoint. A Route Handler is the natural place for this:

tsx
// app/api/webhooks/stripe/route.ts
import { verifyStripeSignature, handleStripeEvent } from '@/lib/stripe';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');

  if (!verifyStripeSignature(body, signature)) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const event = JSON.parse(body);
  await handleStripeEvent(event);

  return Response.json({ received: true });
}

Third-party callbacks. In a ride-hailing platform I worked on, our mapping provider sent location updates via HTTP POST. Route Handlers handled that cleanly:

tsx
// app/api/location-updates/route.ts
export async function POST(req: Request) {
  const { driverId, lat, lon, timestamp } = await req.json();
  await updateDriverLocation(driverId, { lat, lon, timestamp });
  return Response.json({ ok: true });
}

Mobile and third-party clients. If you have a React Native app, Flutter app, or external partners consuming your API, Route Handlers let you expose whatever endpoints they need without leaving your Next.js codebase. You control versioning, authentication, and rate-limiting all in one place.

Public APIs. Sometimes you want to expose data or functionality publicly. Route Handlers are perfect for this—you can add CORS headers, implement rate-limiting middleware, and control access based on API keys.

When Not to Use Route Handlers — Server Actions for Internal UI Mutations

Just because Route Handlers exist doesn't mean you should use them for everything. Over the years, I've learned that for mutations triggered by your own UI, Server Actions are often cleaner and more ergonomic.

Consider a form that updates a lead in your CRM. You could create a Route Handler:

tsx
// app/api/leads/[id]/route.ts (not ideal for internal UI)
export async function PATCH(
  req: Request,
  { params }: { params: { id: string } }
) {
  const body = await req.json();
  const lead = await updateLead(params.id, body);
  return Response.json(lead);
}

Then call it from your form:

tsx
// app/leads/[id]/edit-form.tsx (awkward)
'use client';

export function EditLeadForm({ lead }) {
  const handleSubmit = async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const res = await fetch(`/api/leads/${lead.id}`, {
      method: 'PATCH',
      body: JSON.stringify(Object.fromEntries(formData)),
    });
    const updated = await res.json();
    // handle UI updates...
  };

  return ...;
}

With a Server Action, it's simpler and more natural:

tsx
// app/leads/[id]/edit-lead-action.ts
'use server';

export async function updateLeadAction(id: string, data: any) {
  return updateLead(id, data);
}

// app/leads/[id]/edit-form.tsx
import { updateLeadAction } from './edit-lead-action';

export function EditLeadForm({ lead }) {
  return (
     {
      await updateLeadAction(lead.id, Object.fromEntries(formData));
      // automatic revalidation, no manual fetch()
    }}>
      ...
    
  );
}

Server Actions handle revalidation automatically, avoid the client-side JSON serialization dance, and integrate seamlessly with form submissions. Use them for your internal UI. Reserve Route Handlers for external clients and webhooks.

Not Cached by Default — GET Handlers Are Dynamic Since Next.js 15

This is an important shift worth understanding. In Next.js 14 and earlier, GET handlers were cached by default. Starting with Next.js 15, they're dynamic by default—every request runs the handler fresh.

If you're used to the old behavior, this can surprise you. A simple GET handler:

tsx
// app/api/leads/route.ts
export async function GET(req: Request) {
  const leads = await getLeads();
  return Response.json(leads);
}

This now executes on every request. That's usually what you want for public APIs and webhooks, but if you need caching for performance, you can opt in explicitly using response headers:

tsx
export async function GET(req: Request) {
  const leads = await getLeads();
  return Response.json(leads, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
    },
  });
}

You can also use Next.js's revalidateTag or revalidatePath functions from the data cache API if you need fine-grained control. For example, in an e-commerce platform:

tsx
// app/api/products/route.ts
import { revalidateTag } from 'next/cache';

export async function GET(req: Request) {
  const products = await getProducts();
  return Response.json(products);
}

export async function POST(req: Request) {
  const body = await req.json();
  const product = await createProduct(body);
  revalidateTag('products');
  return Response.json(product, { status: 201 });
}

When you create a product, you invalidate the products tag. Any query tagged with 'products' gets recomputed on the next request. This pattern keeps your data fresh without caching everything indefinitely.

Common Mistakes

  • Forgetting to return a Response object. Always return a valid Response. Response.json(data) is your friend. Returning plain objects will cause errors.
  • Not validating webhook signatures. If you're handling webhooks, always verify the signature. Attackers can forge requests to your endpoints. Check the provider's docs (Stripe, Razorpay, etc.) for how to verify.
  • Mixing internal mutations into Route Handlers. If it's triggered by your own UI, use a Server Action instead. You'll have fewer bugs and cleaner code.
  • Assuming GET requests are always cached. As of Next.js 15, they're not. If you need caching, set headers explicitly.
  • Not handling errors gracefully. Route Handlers should return appropriate HTTP status codes. A 500 error with a helpful message beats silent failures in production.

Key Takeaways

  • Route Handlers are HTTP endpoints defined in your app directory. Export async functions named GET, POST, PATCH, DELETE, etc., and Next.js wires them up automatically.
  • Use them for webhooks, third-party callbacks, and public APIs. They're the natural home for any HTTP endpoint your app needs to expose.
  • For internal UI mutations triggered by your own forms and components, prefer Server Actions instead. They're simpler and integrate better with Next.js's revalidation system.
  • GET handlers are dynamic by default in Next.js 15. If you need caching, opt in explicitly with Cache-Control headers or use revalidateTag for fine-grained control.
  • Always validate webhook signatures, return proper HTTP status codes, and treat Route Handlers as a real API surface—security and error handling matter.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles