Route handlers
When something from outside needs access
A route.ts answers an address with data instead of a page. Meant for access from outside – not for your own interface.
Reading is the server component's job, writing the server action's. That leaves a gap: what if a third-party system wants to talk to you? That is what the Route handlerA `route.ts` that answers an address without being a page – your way to a custom API.→ is for.
export async function GET() {
const posts = await db.post.findMany();
return Response.json(posts);
}
export async function POST(request: Request) {
const data = await request.json();
const created = await db.post.create({ data });
return Response.json(created, { status: 201 });
} Only one of the two may live in a folder. An address is either a page or an endpoint. That is why handlers usually sit under app/api/….
What they are really for
- +
Webhooks: payment providers, a CMS, GitHub calling you
- +
A public interface for third-party systems or a mobile app
- +
Serving files: generated images, PDFs, CSV exports
- +
OAuth callbacks and similar redirects
- −
Your own server component calling your own route – a network detour to yourself
- −
Forms in your own interface – server actions are shorter and safer
- −
Data for your own client component that could just be passed down
Server actions are the front door for family – short, direct, no formalities. A route handler is the delivery entrance: documented, with an address, for people from outside.
What is inside
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q") ?? "";
if (!q) {
return Response.json({ error: "q is missing" }, { status: 400 });
}
const hits = await db.post.findMany({
where: { title: { contains: q } },
});
return Response.json(hits);
} export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; // await here too
await db.post.delete({ where: { id } });
return new Response(null, { status: 204 });
} Unlike a page there is no layout and no check in front of them. Whoever knows the address can call it. So every handler checks for itself: is there a valid key? Does the webhook signature match? Is this person allowed?
Caching
// GET handlers are dynamic by default – every request runs through.
// To cache the result:
export const revalidate = 60;
// Or explicitly always fresh:
export const dynamic = "force-dynamic"; ▸ In more depth: a webhook that revalidates optional
The most common sensible use: a CMS reports that something was published, and your site clears the matching cache in response.
import { revalidateTag } from "next/cache";
export async function POST(request: Request) {
const key = request.headers.get("x-webhook-key");
if (key !== process.env.WEBHOOK_KEY) {
return new Response("Not allowed", { status: 401 });
}
const { type } = await request.json();
revalidateTag(type); // e.g. "posts"
return Response.json({ ok: true });
} Without that first check anyone could make your site rebuild as often as they like. The key belongs in an environment variable without NEXT_PUBLIC_ – otherwise it ends up in the browser.
Does it stick?
4 questions on this lesson. Wrong answers show up in your stats.