Server Actions cover most mutations. Use Route Handlers when you need:
1// app/api/courses/route.ts
2import { NextRequest } from "next/server";
3
4export async function GET(req: NextRequest) {
5 const q = req.nextUrl.searchParams.get("q") ?? "";
6 const courses = await db.course.findMany({
7 where: { title: { contains: q, mode: "insensitive" } },
8 select: { id: true, title: true, slug: true },
9 take: 20,
10 });
11 return Response.json(courses);
12}
13
14export async function POST(req: NextRequest) {
15 const body = await req.json();
16 // … validate, auth, create
17 return Response.json({ id: "new-id" }, { status: 201 });
18}GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS — each is a named export.
1// app/api/courses/[id]/route.ts
2export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
3 const { id } = await params;
4 const course = await db.course.findUnique({ where: { id } });
5 if (!course) return Response.json({ error: "Not found" }, { status: 404 });
6 return Response.json(course);
7}1// app/api/webhooks/stripe/route.ts
2import Stripe from "stripe";
3
4const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
5
6export async function POST(req: NextRequest) {
7 const sig = req.headers.get("stripe-signature");
8 if (!sig) return new Response("Missing signature", { status: 400 });
9
10 const body = await req.text(); // .text() — Stripe needs the raw body
11 let event: Stripe.Event;
12 try {
13 event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
14 } catch (e) {
15 return new Response("Invalid signature", { status: 400 });
16 }
17
18 if (event.type === "checkout.session.completed") {
19 const s = event.data.object as Stripe.Checkout.Session;
20 await fulfillOrder(s.metadata!.orderId);
21 }
22
23 return new Response("ok");
24}1export async function GET() {
2 const encoder = new TextEncoder();
3 const stream = new ReadableStream({
4 async start(controller) {
5 for (let i = 0; i < 10; i++) {
6 controller.enqueue(encoder.encode(`data: tick ${i}\n\n`));
7 await new Promise((r) => setTimeout(r, 500));
8 }
9 controller.close();
10 },
11 });
12 return new Response(stream, {
13 headers: {
14 "Content-Type": "text/event-stream",
15 "Cache-Control": "no-cache",
16 Connection: "keep-alive",
17 },
18 });
19}Server-Sent Events for live dashboards, LLM token streams, build logs.
1export async function OPTIONS() {
2 return new Response(null, {
3 headers: {
4 "Access-Control-Allow-Origin": "https://your-mobile-app.com",
5 "Access-Control-Allow-Methods": "GET, POST",
6 "Access-Control-Allow-Headers": "Content-Type, Authorization",
7 },
8 });
9}For SaaS-style APIs called from arbitrary origins, allow * (and don't accept cookies).
1import { Ratelimit } from "@upstash/ratelimit";
2import { Redis } from "@upstash/redis";
3
4const limit = new Ratelimit({
5 redis: Redis.fromEnv(),
6 limiter: Ratelimit.slidingWindow(10, "1 m"),
7});
8
9export async function POST(req: NextRequest) {
10 const ip = req.headers.get("x-forwarded-for") ?? "anon";
11 const { success } = await limit.limit(ip);
12 if (!success) return new Response("Too many requests", { status: 429 });
13 // …
14}