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.