Route Handlers and Typed API Endpoints
Route Handlers let an App Router project expose HTTP endpoints from files named route.ts. They are the Next.js replacement for many traditional API routes when you are working inside the app directory. The outcome of this lesson is practical: you will be able to create handlers for GET, POST, and other HTTP methods, validate incoming data, return typed JSON, and call those endpoints from client code without guessing response shapes.
This matters in the Data and Mutations section because not every data operation belongs directly inside a Server Component or Server Action. A Route Handler is useful when another service needs HTTP access, when a Client Component needs an endpoint, when you need a webhook receiver, or when you want a small public API backed by the same application code and deployment.
How Route Handlers Work
A Route Handler is discovered by the file-system router. A file such as app/api/courses/route.ts owns the URL path /api/courses. Inside that file, exported functions named after HTTP methods handle matching requests. Common exports are GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. If a request uses a method you did not export, Next.js responds as unsupported for that route.
The handler receives a Web Request object, or a NextRequest when you need Next-specific helpers such as parsed cookies or nextUrl. It returns a Web Response, usually through Response.json() or NextResponse.json(). This is deliberately close to standard Fetch API primitives, so the same mental model applies: read headers, parse the body once, choose a status code, and return bytes plus headers.
Route Handlers run on the server, never in the browser bundle. That means they can read environment variables, access databases, and use private SDK credentials. It also means they must treat every request as external input. Even when a request comes from your own Client Component, the browser user can modify the URL, body, headers, and method.
Typed Endpoint Anatomy
TypeScript types help the code you write, but they do not validate network input at runtime. A typed API endpoint usually has three layers: a runtime schema for request parsing, TypeScript types inferred from that schema, and a response type that callers can narrow safely. Libraries such as Zod are common because they give one source of truth for runtime validation and static inference.
A useful route file normally separates four concerns. First, parse the request from HTTP into unknown values. Second, validate those values into a typed command or query. Third, execute the server-side operation. Fourth, serialize a response with a deliberate status code. Keeping those steps visible makes failures easier to diagnose and prevents a handler from accepting data just because TypeScript believed the caller.
Example 1: A Typed GET Endpoint
This first example returns a list of courses. The response type is defined in the same module so both the handler and client code can agree on the JSON shape. In a larger app, shared API types usually move into a small lib/api-types.ts file that contains no server-only imports.
type CourseSummary = {
id: string;
title: string;
level: "beginner" | "intermediate" | "advanced";
};
type CoursesResponse = {
courses: CourseSummary[];
};
export async function GET(): Promise<Response> {
const body: CoursesResponse = {
courses: [
{ id: "next-101", title: "App Router Basics", level: "beginner" },
{ id: "next-220", title: "Data and Mutations", level: "intermediate" }
]
};
return Response.json(body, { status: 200 });
}
A request to /api/courses returns status 200 with a deterministic JSON body containing a courses array. The type annotation does not change the runtime response, but it makes accidental fields and invalid level names fail during development. If you changed level to expert, TypeScript would reject the handler code before deployment.
Example 2: Validating a POST Body
A POST endpoint accepts untrusted data. The important detail is that await request.json() returns any or unknown-like data from the network, not a trusted TypeScript object. The schema below converts that unknown payload into a safe command before the mutation runs.
import { z } from "zod";
const CreateCourseSchema = z.object({
title: z.string().trim().min(3).max(120),
level: z.enum(["beginner", "intermediate", "advanced"])
});
type CreateCourseInput = z.infer<typeof CreateCourseSchema>;
type CreateCourseResponse =
| { ok: true; course: { id: string; title: string; level: CreateCourseInput["level"] } }
| { ok: false; error: string };
export async function POST(request: Request): Promise<Response> {
const json = await request.json().catch(() => null);
const parsed = CreateCourseSchema.safeParse(json);
if (!parsed.success) {
const body: CreateCourseResponse = { ok: false, error: "Invalid course payload" };
return Response.json(body, { status: 400 });
}
const course = {
id: crypto.randomUUID(),
title: parsed.data.title,
level: parsed.data.level
};
const body: CreateCourseResponse = { ok: true, course };
return Response.json(body, { status: 201 });
}
With body {"title":"Route Handlers","level":"intermediate"}, the endpoint returns 201 and an ok: true response. With {"title":"x","level":"expert"}, it returns 400 and a stable error shape. Notice that the correction is not to cast the body as CreateCourseInput. A cast only silences TypeScript; it does not protect the running server.
Example 3: A Typed Client Helper
The client side should also avoid loose fetch calls scattered through components. A small helper can centralize the URL, method, headers, serialization, response parsing, and error handling. The helper imports only types, so it does not pull server code into the browser bundle.
type CreateCourseResponse =
| { ok: true; course: { id: string; title: string; level: "beginner" | "intermediate" | "advanced" } }
| { ok: false; error: string };
export async function createCourse(input: {
title: string;
level: "beginner" | "intermediate" | "advanced";
}): Promise<CreateCourseResponse> {
const response = await fetch("/api/courses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input)
});
const data = (await response.json()) as CreateCourseResponse;
if (!response.ok && data.ok) {
return { ok: false, error: "Unexpected API response" };
}
return data;
}
A component calling createCourse({ title: "Caching", level: "intermediate" }) receives a discriminated union. If result.ok is true, TypeScript knows result.course exists. If false, TypeScript knows result.error exists. The endpoint still performs runtime validation because client-side types are a convenience, not a security control.
Design Choices and Trade-Offs
Use a Route Handler when HTTP is the right interface. It is a good fit for webhooks, third-party callbacks, file downloads, RSS feeds, public JSON endpoints, and Client Component operations that should not use a Server Action. Prefer a Server Component for read-only data that is only needed during page rendering. Prefer a Server Action for form-like mutations tightly coupled to your React tree. The trade-off is explicitness: Route Handlers give you a familiar HTTP boundary, but you must manage request parsing, status codes, and client helpers yourself.
Choose status codes according to the failure category. Use 400 for malformed input, 401 when authentication is missing, 403 when the identity is known but not allowed, 404 when the resource should not be exposed, 409 for state conflicts, and 500 for unexpected server failures. Typed response unions should mirror those categories closely enough that client code can present the right state without string matching stack traces.
Caching also needs an explicit decision. A GET handler that returns public, stable data can use cache headers. A handler that depends on cookies, authorization, or rapidly changing user data should be dynamic and avoid shared public caching. Mutation handlers should normally return the changed representation or enough identifiers for the UI to re-fetch and reconcile.
Failure Modes and Troubleshooting
- Symptom: the handler returns
405or appears missing. Cause: the file is not namedroute.ts, is placed at the wrong route segment, or does not export the requested method. Diagnostic: compare the URL with theappdirectory path and inspect exported function names. Correction: move the file to the intended segment and export the exact uppercase HTTP method. - Symptom: valid JSON requests fail with body parsing errors after middleware or helper code runs. Cause: the request body stream was read more than once. Diagnostic: search for multiple calls to
request.json(),request.text(), or logging helpers that consume the body. Correction: parse once near the top of the handler and pass the parsed value to downstream functions. - Symptom: TypeScript says the endpoint is typed, but production accepts invalid fields. Cause: compile-time types were used without runtime validation. Diagnostic: send a request with an impossible enum value using
curlor an API client. Correction: validate with a runtime schema before executing the operation. - Symptom: one user’s data appears in another user’s cached response. Cause: personalized
GEToutput was cached as if it were public. Diagnostic: check whether the handler reads cookies or authorization headers and inspect response cache headers. Correction: mark the route dynamic or return private/no-store cache headers for personalized data.
Security, Performance, and Reliability
Route Handlers should authenticate before reading or changing private data, but authorization must happen at the operation being performed. A user who can read a course is not automatically allowed to publish it. Avoid returning raw validation details when they reveal internal field names that are not part of the public API. Log bounded request identifiers, status categories, and timing; do not log secrets, session tokens, or entire request bodies.
For performance, keep handlers small and avoid unnecessary waterfalls. If a handler needs independent database reads, run them concurrently after authentication. For large responses, prefer pagination or streaming formats instead of returning unbounded arrays. For reliability, make mutation handlers idempotent when clients may retry, especially for payment, email, or provisioning endpoints.
Hands-On Lab
Prerequisites: a Next.js App Router project using TypeScript, Node installed, and a terminal in the project root. If the project does not already have Zod, install it with your package manager.
- Create
app/api/courses/route.tswith theGETandPOSTpatterns from the examples. - Run the development server and request
http://localhost:3000/api/coursesin a browser or withcurl. Verify that the response is200and contains acoursesarray. - Send a valid
POSTrequest withtitleandlevel. Verify status201,ok: true, and a generatedid. - Send an invalid
POSTrequest with a two-character title or an unsupported level. Verify status400andok: false. - Add the client helper to
lib/courses-api.tsand call it from a Client Component. Verify that TypeScript narrows the response after checkingresult.ok.
Cleanup is simple for this lab: remove the route file and helper if they were created only for practice. If you installed Zod solely for the exercise, remove it with the matching package manager command and restart the dev server to confirm the app still builds.
Assessment Exercises
- Design a
PATCH /api/courses/[id]response union that distinguishes validation failure, not found, forbidden, and success. Which fields should each variant expose to the client? - A teammate writes
const body = await request.json() as CreateCourseInput. Explain why this is unsafe and replace it with a safer pattern. - You need an endpoint for a payment provider webhook. Why is a Route Handler a better fit than a Server Action, and what validation would you perform before changing database state?
- A personalized
GEThandler reads a session cookie. What caching behavior would you choose, and how would you verify that another user cannot receive the same response? - Refactor a repeated
fetchcall into a typed helper. Show how the calling component handles both the success and error branches without assuming the response shape.
Summary
Route Handlers give a Next.js App Router project a server-side HTTP surface built from standard request and response primitives. Typed endpoints become reliable when TypeScript response shapes are paired with runtime request validation, deliberate status codes, and narrow client helpers. Use them when HTTP itself is the right boundary, keep server-only code out of shared client modules, and test both the accepted and rejected paths.
