After this lesson, you will be able to: Build REST API endpoints inside your Next.js app using the App Router's `route.ts` files, handle GET and POST requests with proper status codes, validate inputs with Zod, and call them from your client components.
API routes give your Next.js app a backend without spinning up a separate server. Create a `route.ts` file under `app/api/`, export functions named after HTTP methods, and you have endpoints — deployed to the same Vercel infrastructure as your pages, automatically. This lesson takes you from never having written an API route to building a small REST API for your own app: handle requests, validate input, return JSON with correct status codes.
A `route.ts` file in the `app/api/` folder. Export functions named `GET`, `POST`, `PUT`, `PATCH`, `DELETE` — those are the HTTP methods the endpoint handles. Each function receives the standard Web `Request` object and returns a `Response`. Folder name = URL segment, same as pages. `app/api/posts/route.ts` → `POST /api/posts`. `app/api/posts/[id]/route.ts` → `GET /api/posts/123`. The same file-based mental model as the App Router.
Simplest endpoint: query a list, return it as JSON. `NextResponse.json` sets the right Content-Type for you and is the standard helper.
// app/api/posts/route.tsimport { NextResponse } from "next/server";// In real apps, this comes from a database (Supabase, Postgres via Prisma, etc.)const posts = [{ id: 1, title: "Hello Next.js" },{ id: 2, title: "Why I love TypeScript" },];export async function GET() {return NextResponse.json(posts);}
Real APIs validate input. Zod is the standard TS-first validator — declare the shape once, get parsing AND types from it. Reject bad input with a 400, otherwise return 201 with the created resource.
// app/api/posts/route.tsimport { NextResponse } from "next/server";import { z } from "zod";const postSchema = z.object({title: z.string().min(1).max(200),body: z.string().min(1),});let posts = [{ id: 1, title: "Hello", body: "..." }];let nextId = 2;export async function POST(request: Request) {const body = await request.json().catch(() => null);const result = postSchema.safeParse(body);if (!result.success) {return NextResponse.json({ error: "Invalid input", details: result.error.issues },{ status: 400 },);}const post = { id: nextId++, ...result.data };posts.push(post);return NextResponse.json(post, { status: 201 });}
Same `[param]/` syntax as pages. The `params` object is a Promise in Next 15+, so await it.
// app/api/posts/[id]/route.tsimport { NextResponse } from "next/server";export async function GET(_request: Request,{ params }: { params: Promise<{ id: string }> },) {const { id } = await params;const post = posts.find((p) => p.id === Number(id));if (!post) {return NextResponse.json({ error: "Not found" }, { status: 404 });}return NextResponse.json(post);}
The wd-apis lesson covered HTTP status codes — apply them on the SERVER side now. Successful GET → 200. Successful POST that created a resource → 201. Bad input → 400. Missing auth → 401. Authenticated but forbidden → 403. Not found → 404. Server error → 500. Wrap risky operations in try/catch so a database failure returns a clean 500 instead of crashing the server.
Same `fetch` pattern as the wd-apis lesson. Use a relative URL since the API lives on the same origin.
"use client";import { useState } from "react";export function NewPostForm() {const [title, setTitle] = useState("");const [body, setBody] = useState("");const [status, setStatus] = useState<"idle" | "saving" | "error" | "saved">("idle");async function submit(event: React.FormEvent) {event.preventDefault();setStatus("saving");try {const response = await fetch("/api/posts", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({ title, body }),});if (!response.ok) throw new Error(`HTTP ${response.status}`);setStatus("saved");setTitle("");setBody("");} catch {setStatus("error");}}return (<form onSubmit={submit} className="space-y-3"><input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" className="w-full border p-2" /><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Body" className="w-full border p-2" /><button disabled={status === "saving"} className="rounded bg-slate-900 px-4 py-2 text-white">{status === "saving" ? "Saving..." : "Create post"}</button>{status === "error" && <p className="text-red-600">Could not save.</p>}</form>);}
Extend `app/api/posts/[id]/route.ts` to handle a DELETE request. Remove the matching post from the array and return 204 No Content on success, 404 if the post doesn't exist. Required patterns check for the `DELETE` export, the params await, and the correct status codes.
Sign in and purchase access to unlock this lesson.