Next.js · lesson 3 of 11
Route Handlers
A Next.js 16 Route Handler answers curl requests: GET and POST, a thrown error's 500, a caught 400, and the automatic 405 for an unhandled method.
By SkillByExample · Updated
A Route Handler is a route.ts file under app/, and the HTTP methods it answers are
exactly the function names it exports — GET, POST, and so on. The project setup below
reuses the Docker pattern from the first lesson (the
node:26.13 image, the nextjs-modules volume, MSYS_NO_PATHCONV=1 on Git Bash); see it
for why each flag is there.
A Route Handler Exports HTTP Methods
Five files set up the project: package.json, next.config.ts, tsconfig.json, and
app/layout.tsx are unchanged from the previous lessons; app/page.tsx carries a new
comment, since this lesson is about app/api/, not the home page.
{
"name": "next-app",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.4.3",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "20.19.43",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0"
}
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
// app/layout.tsx — required; every page in the app renders inside this
export const metadata = {
title: "Next App",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
// app/page.tsx — this lesson is about app/api/, not this page
export default function HomePage() {
return <h1>Home</h1>;
}
app/api/hello/route.ts is not a page — there’s no JSX, no default export, just one named
export per HTTP method it answers. GET reads a query parameter straight off the request’s
own URL and returns JSON:
// app/api/hello/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const name = request.nextUrl.searchParams.get("name") ?? "World";
return NextResponse.json({ message: `Hello, ${name}` });
}
The App Router and the Route Table’s /profile
route earned ◐ for reading a request header inside a <Suspense> boundary — a static
shell with one dynamic hole. This route has no shell at all; predict what marker a route
whose entire response depends on the request gets instead, before building:
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund
added 28 packages in 29s
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 97ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 5.7s
Running TypeScript ...
Finished TypeScript in 3.5s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /api/hello
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
/api/hello gets ƒ, a marker that hasn’t shown up before — (Dynamic) server-rendered on demand. A Route Handler produces no static HTML shell for Cache Components to prerender
around; reading request.nextUrl.searchParams means the whole response is computed fresh
for every request. Start the built app and ask it for the route with and without the query
parameter:
$ docker run -d --name nextjs-route-handlers-1 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
3a28b7029d0e7282ebece87f9bdc6120ebaa8394a13b7160c56a0506f75e1928
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s "http://localhost:3100/api/hello?name=Ada"
{"message":"Hello, Ada"}
$ curl -s http://localhost:3100/api/hello
{"message":"Hello, World"}
$ docker rm -f nextjs-route-handlers-1
nextjs-route-handlers-1
?name=Ada becomes "Hello, Ada"; leaving it off falls through to the ?? "World"
default. Nothing here differs from reading a query string in any other Node server — the
only Next.js-specific part is that request.nextUrl is already a parsed URL, no manual
parsing required.
POST Reads the Request Body
Add a POST export to the same file. request.json() parses the request body and returns
a Promise, so it has to be awaited:
// app/api/hello/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const name = request.nextUrl.searchParams.get("name") ?? "World";
return NextResponse.json({ message: `Hello, ${name}` });
}
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ received: body });
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 100ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.2s
Running TypeScript ...
Finished TypeScript in 3.6s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /api/hello
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-route-handlers-2 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
3a96b5b52654f17ab88173522a9f944e0eff1cd49f04bd544ce8c1c73b14ec10
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -X POST -H "Content-Type: application/json" -d '{"x":1}' http://localhost:3100/api/hello
{"received":{"x":1}}
$ docker rm -f nextjs-route-handlers-2
nextjs-route-handlers-2
The parsed body comes back wrapped exactly as the handler wrote it: {"received":{"x":1}}.
request.json() is doing real JSON.parse work on whatever bytes the client sent — which
is exactly what the next section breaks on purpose.
A Malformed Body Crashes the Handler
No file changes yet — the same build from the previous section is still sitting in
.next. Send POST a body that isn’t valid JSON at all:
$ docker run -d --name nextjs-route-handlers-3 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
cddba1c800ff12d0eb9d84753eb803e55e219ea6769a1b2c48612aa2640e4a97
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i -X POST -H "Content-Type: application/json" -d 'not-json' http://localhost:3100/api/hello
HTTP/1.1 500 Internal Server Error
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Date: Wed, 04 Nov 2026 07:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
$ docker logs nextjs-route-handlers-3 2>&1 | tail -3
✓ Running next.config.ts took 104ms
⨯ SyntaxError: Unexpected token 'o', "not-json" is not valid JSON
at JSON.parse (<anonymous>)
$ docker rm -f nextjs-route-handlers-3
nextjs-route-handlers-3
The client gets a bare 500 Internal Server Error with no body at all — not even a hint
that the problem was the request it sent. await request.json() threw a real
SyntaxError, nothing in the handler caught it, and Next.js’s own framework code turned
the uncaught exception into a generic 500. The real reason — that the body wasn’t valid
JSON — only exists in the server’s own log, which a client calling this API would never
see.
Catching It: a 400 Instead of a 500
A try/catch around the one call that can throw turns an opaque crash into a response
that actually says what was wrong. While editing the handler, add a second check too — the
body has to contain a name string, not just be valid JSON:
// app/api/hello/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const name = request.nextUrl.searchParams.get("name") ?? "World";
return NextResponse.json({ message: `Hello, ${name}` });
}
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "body must be valid JSON" }, { status: 400 });
}
const { name } = body as { name?: unknown };
if (typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
return NextResponse.json({ message: `Hello, ${name}` }, { status: 201 });
}
NextResponse.json’s second argument sets the status code explicitly — 400 for either
failure, 201 Created once the request actually creates something. Rebuild and try all
three cases: the same malformed body, valid JSON missing name, and a valid request:
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 56ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.3s
Running TypeScript ...
Finished TypeScript in 2.3s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /api/hello
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-route-handlers-4 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
c7321480e901051f0f7c2dc8ad5dea7800702f749f75529659fc6ef778e34273
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i -X POST -H "Content-Type: application/json" -d 'not-json' http://localhost:3100/api/hello
HTTP/1.1 400 Bad Request
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Date: Wed, 04 Nov 2026 07:18:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"error":"body must be valid JSON"}
$ curl -s -i -X POST -H "Content-Type: application/json" -d '{"x":1}' http://localhost:3100/api/hello
HTTP/1.1 400 Bad Request
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Date: Wed, 04 Nov 2026 07:18:49 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"error":"name is required"}
$ curl -s -i -X POST -H "Content-Type: application/json" -d '{"name":"Ada"}' http://localhost:3100/api/hello
HTTP/1.1 201 Created
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Date: Wed, 04 Nov 2026 07:18:49 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"message":"Hello, Ada"}
$ docker rm -f nextjs-route-handlers-4
nextjs-route-handlers-4
Three different outcomes for three different problems, each with its own status code and a body that names what to fix — a real improvement over the single opaque 500 from the previous section.
An Unsupported Method Returns 405 Automatically
No file changes — the same build from the previous section is still current. route.ts
exports GET and POST; nothing exports DELETE:
$ docker run -d --name nextjs-route-handlers-5 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
f64fda5ae02aa7ffddfeb07211727d5b589bf429552b60ce381a6e4d0d51876a
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i -X DELETE http://localhost:3100/api/hello
HTTP/1.1 405 Method Not Allowed
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Date: Wed, 04 Nov 2026 07:18:54 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
$ docker rm -f nextjs-route-handlers-5
nextjs-route-handlers-5
No code anywhere decided this — there’s no if (method === "DELETE") to write. The module
itself still loads normally (its imports run, same as for any request), but Next.js
dispatches to the exported function matching the request’s method, and no file-level code
ever runs beyond that: there’s no DELETE export to call, so it returns 405 Method Not Allowed on its own instead. The response has no body and no Allow header naming which
methods would have worked — the status code is the only signal (Practice 6 contrasts this
with OPTIONS, which does get an automatic Allow header).
Practice
Each task below builds on the files the previous ones (and the main lesson above) left in place — do them in order.
1. Require a second field, age, as a number in the POST body. Return 400 with a
message naming the problem if it’s missing or not a number; include it in the success
response.
Solution
// app/api/hello/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const name = request.nextUrl.searchParams.get("name") ?? "World";
return NextResponse.json({ message: `Hello, ${name}` });
}
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "body must be valid JSON" }, { status: 400 });
}
const { name, age } = body as { name?: unknown; age?: unknown };
if (typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
if (typeof age !== "number") {
return NextResponse.json({ error: "age must be a number" }, { status: 400 });
}
return NextResponse.json({ message: `Hello, ${name}`, age }, { status: 201 });
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 75ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.3s
Running TypeScript ...
Finished TypeScript in 2.4s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /api/hello
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-route-handlers-6 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
97264ddfb89311c510b4a588a4c453e00baa99c2a70efa95976218b8fb48df9a
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i -X POST -H "Content-Type: application/json" -d '{"name":"Ada"}' http://localhost:3100/api/hello
HTTP/1.1 400 Bad Request
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Date: Wed, 04 Nov 2026 07:19:12 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"error":"age must be a number"}
$ curl -s -i -X POST -H "Content-Type: application/json" -d '{"name":"Ada","age":36}' http://localhost:3100/api/hello
HTTP/1.1 201 Created
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Date: Wed, 04 Nov 2026 07:19:12 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"message":"Hello, Ada","age":36}
$ docker rm -f nextjs-route-handlers-6
nextjs-route-handlers-6
age missing fails with its own message, separate from the name check; sending both
fields succeeds and echoes age back as a real JSON number, not a string.
2. Add a length check to GET: if name is longer than 20 characters, return 400
instead of echoing it back.
Solution
// app/api/hello/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const name = request.nextUrl.searchParams.get("name") ?? "World";
if (name.length > 20) {
return NextResponse.json({ error: "name is too long" }, { status: 400 });
}
return NextResponse.json({ message: `Hello, ${name}` });
}
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "body must be valid JSON" }, { status: 400 });
}
const { name, age } = body as { name?: unknown; age?: unknown };
if (typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
if (typeof age !== "number") {
return NextResponse.json({ error: "age must be a number" }, { status: 400 });
}
return NextResponse.json({ message: `Hello, ${name}`, age }, { status: 201 });
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 72ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.7s
Running TypeScript ...
Finished TypeScript in 2.8s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /api/hello
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-route-handlers-7 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
c97204ad7f9f7712da8fe404e9736804fc01f6601af0479b3b6e9cfe3c8f8a25
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i "http://localhost:3100/api/hello?name=Ada"
HTTP/1.1 200 OK
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Date: Wed, 04 Nov 2026 07:19:32 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"message":"Hello, Ada"}
$ curl -s -i "http://localhost:3100/api/hello?name=ThisNameIsWayTooLongForTheLimit"
HTTP/1.1 400 Bad Request
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Date: Wed, 04 Nov 2026 07:19:33 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"error":"name is too long"}
$ docker rm -f nextjs-route-handlers-7
nextjs-route-handlers-7
Ada is 3 characters and passes through unchanged; the 31-character name trips the new
check and gets 400 with {"error":"name is too long"} instead of ever reaching the
NextResponse.json({ message: ... }) line.
3. Add a PUT export that updates the name and responds with 200. Confirm PUT now
works while a still-unimplemented method, PATCH, still returns 405.
Solution
// app/api/hello/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const name = request.nextUrl.searchParams.get("name") ?? "World";
if (name.length > 20) {
return NextResponse.json({ error: "name is too long" }, { status: 400 });
}
return NextResponse.json({ message: `Hello, ${name}` });
}
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "body must be valid JSON" }, { status: 400 });
}
const { name, age } = body as { name?: unknown; age?: unknown };
if (typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
if (typeof age !== "number") {
return NextResponse.json({ error: "age must be a number" }, { status: 400 });
}
return NextResponse.json({ message: `Hello, ${name}`, age }, { status: 201 });
}
export async function PUT(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "body must be valid JSON" }, { status: 400 });
}
const { name } = body as { name?: unknown };
if (typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
return NextResponse.json({ message: `Updated: ${name}` });
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 84ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.8s
Running TypeScript ...
Finished TypeScript in 2.8s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /api/hello
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-route-handlers-8 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
32b9b0c3ba603e2de10277f2c4fb8af1d83d926e58c759e5961ace654f11b97c
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i -X PUT -H "Content-Type: application/json" -d '{"name":"Ada"}' http://localhost:3100/api/hello
HTTP/1.1 200 OK
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Date: Wed, 04 Nov 2026 07:19:53 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"message":"Updated: Ada"}
$ curl -s -i -X PATCH http://localhost:3100/api/hello
HTTP/1.1 405 Method Not Allowed
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Date: Wed, 04 Nov 2026 07:19:54 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
$ docker rm -f nextjs-route-handlers-8
nextjs-route-handlers-8
PUT now returns 200 OK with the updated message — a real export, dispatched exactly
like GET and POST are. PATCH has no export in this file at all, so it still falls
back to the same automatic 405 the lesson’s own DELETE call got.
4. Generalize the lesson’s first real failure: make GET throw too, not just POST’s
body parsing. Add a crash sentinel value for name that throws an uncaught error, and
confirm the same bare 500 and the same kind of server-only log line come back.
Solution
// app/api/hello/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const name = request.nextUrl.searchParams.get("name") ?? "World";
if (name === "crash") {
throw new Error("boom");
}
if (name.length > 20) {
return NextResponse.json({ error: "name is too long" }, { status: 400 });
}
return NextResponse.json({ message: `Hello, ${name}` });
}
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "body must be valid JSON" }, { status: 400 });
}
const { name, age } = body as { name?: unknown; age?: unknown };
if (typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
if (typeof age !== "number") {
return NextResponse.json({ error: "age must be a number" }, { status: 400 });
}
return NextResponse.json({ message: `Hello, ${name}`, age }, { status: 201 });
}
export async function PUT(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "body must be valid JSON" }, { status: 400 });
}
const { name } = body as { name?: unknown };
if (typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
return NextResponse.json({ message: `Updated: ${name}` });
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 64ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.3s
Running TypeScript ...
Finished TypeScript in 2.4s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /api/hello
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-route-handlers-9 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
793ef30a4ad827865cbdc025896dd489ef74c4cc3b27680441f5602ad24dfe6f
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i "http://localhost:3100/api/hello?name=crash"
HTTP/1.1 500 Internal Server Error
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Date: Wed, 04 Nov 2026 07:20:12 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
$ docker logs nextjs-route-handlers-9 2>&1 | tail -3
✓ Running next.config.ts took 76ms
⨯ Error: boom
at r (.next/server/chunks/[root-of-the-server]__1qvqb-u26m9mp._.js:1:136)
$ docker rm -f nextjs-route-handlers-9
nextjs-route-handlers-9
Same bare 500 Internal Server Error, same shape of problem: a GET handler that throws
is caught by the same framework-level fallback that caught POST’s SyntaxError earlier
in this lesson. The real message, Error: boom, only ever reaches the server’s own log —
whether the throw comes from parsing a body or from code a reader writes deliberately
doesn’t change where Next.js draws the line between “the client gets a response” and “the
server gets the detail.” Notice the log’s own location, though:
.next/server/chunks/[root-of-the-server]__1qvqb-u26m9mp._.js:1:136 is a minified
production build chunk, not app/api/hello/route.ts by name — the build already renamed
and bundled the source file away by the time anything reaches this console.
5. Add a second Route Handler, app/api/static/route.ts, whose GET never reads
anything from the request at all. Confirm it builds as ○, right next to /api/hello’s
ƒ — the contrast FAQ 2 above describes.
Solution
// app/api/static/route.ts — GET never touches the request
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ status: "ok" });
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 107ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 3.1s
Running TypeScript ...
Finished TypeScript in 2.6s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/hello
└ ○ /api/static
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-route-handlers-10 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
6cb9413778c8121c116e6b6a3d9e9fe3fa99d3ab140796e5ddc9757ecad9c24d
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/api/static
{"status":"ok"}
$ docker rm -f nextjs-route-handlers-10
nextjs-route-handlers-10
/api/static lands on ○ in the same route table where /api/hello still shows ƒ —
one Route Handler, one marker rule, applied per file: nothing to prerender around means
ƒ, nothing request-specific to wait for means ○.
6. Call OPTIONS on /api/hello (now exporting GET, POST, and PUT) without
adding an OPTIONS export of its own. Confirm it answers automatically, and compare the
response to the same call against /api/static (which only exports GET, and built ○
instead of ƒ).
Solution
No file changes — both route files are already in place from the previous tasks.
$ docker run -d --name nextjs-route-handlers-11 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
1979ce183c4648b1eb269c79f3be220f007aee5300997af1de661dd8bcd6a216
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i -X OPTIONS http://localhost:3100/api/hello
HTTP/1.1 204 No Content
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
allow: GET, HEAD, OPTIONS, POST, PUT
Date: Wed, 04 Nov 2026 07:20:40 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -s -i -X OPTIONS http://localhost:3100/api/static
HTTP/1.1 405 Method Not Allowed
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Allow: GET
Allow: HEAD
Date: Wed, 04 Nov 2026 07:20:40 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
Method Not Allowed
$ docker rm -f nextjs-route-handlers-11
nextjs-route-handlers-11
The two answers to the same un-exported OPTIONS aren’t even the same shape of response.
/api/hello gets 204 No Content and one allow header listing every method the file
exports, HEAD and OPTIONS included even though neither has its own export. /api/static
gets 405 Method Not Allowed instead, with a body (Method Not Allowed) and two separate
Allow headers, one per method it supports (GET, HEAD — confirming HEAD is treated as
automatic here too, the same as on /api/hello). The one file-level difference the lesson
changed on purpose was which methods each file exports, but the build table already flagged
something else: /api/static built ○, /api/hello built ƒ. Next.js’s own automatic
handling for an un-exported method isn’t one fixed response — a static route and a dynamic
route answer it differently.