Next.js75 min total · 12 parts
Next.js Fundamentals: The App Router, Server Components, and the Caching Rules That Just Flipped
Part 9 of 12 · ~2 min
Route Handlers & API Routes
A Route Handler lives in a route.ts file under app/ and exports functions named after HTTP methods — a genuine JSON API endpoint sitting inside the Next.js app itself rather than a page. OpenRoles needs two of these: one for partner sites listing its openings, another to receive events from Stripe.
// app/api/jobs/route.ts — a public read-only feed, for partner sites
export async function GET() {
const jobs = await db.job.findMany({ where: { published: true } });
return Response.json(jobs);
}
// app/api/webhooks/stripe/route.ts — Stripe calls this, not a person
import { revalidateTag } from "next/cache";
export async function POST(request: Request) {
const event = await request.json();
if (event.type === "checkout.session.completed") {
const jobId = event.data.object.metadata.jobId;
await db.job.update({ where: { id: jobId }, data: { featured: true } });
revalidateTag("jobs", "max"); // the homepage's featured ordering just changed
}
return Response.json({ received: true });
}
Underneath, this is the ordinary Request-in, Response-out contract browsers and servers have always spoken — full say over status codes, headers, whatever shape you want the body to take — it just happens to be defined inside your Next.js project instead of a standalone backend. A handler scoped to one job — app/api/jobs/[slug]/route.ts — carries the same async-params requirement from chapter 4:
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const job = await db.job.findUnique({ where: { slug } });
if (!job) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(job);
}
Picking a Route Handler over a Server Action
The question that settles it, almost every time, is where the caller lives:
- If only OpenRoles's own UI ever needs to trigger it, and calling it like a function is the natural shape — a Server Action wins. Less code to write, works from a form with no JavaScript required, no separate
fetchcall to keep in sync. - If the caller isn't your own component tree at all — a partner's backend, Stripe's webhook delivery system, a browser extension pulling listings — that's a Route Handler's job. It speaks ordinary, addressable HTTP to whatever can send a request, not just your own rendered UI. Stripe has no notion of what a Server Action even is; it needs a URL to send a POST to, which a Route Handler is and a Server Action isn't.
OpenRoles ends up with both at once, which is a genuinely common real shape: Route Handlers as the actual public surface for anything outside the app itself (the partner feed, the Stripe webhook), and Server Actions sitting on top of that same underlying data for the app's own UI — so nobody's hand-writing a fetch call to their own API purely because that API already happens to exist for somebody else's benefit.