Skip to content
Chapter 11Lesson 2

Status codes and the Problem Details body

How HTTP status codes and the RFC 9457 Problem Details body declare an endpoint's outcome to the rest of the stack.

A status code declares the category an outcome falls into, and every layer downstream reads it to decide what happens next. Alerting rules, the CDN, and the load balancer key off the first digit, the class. The client codes against the specific code, and the body fills in the detail. Pick the wrong class and you page the wrong team; pick the wrong code within a class and you confuse retry policies across the stack.

An HTTP response opens with a status line like HTTP/3 200 OK: a protocol version, a three-digit number, and a human-readable phrase. The number is what the rest of the stack reads; the phrase (“OK”, “Not Found”, “Internal Server Error”) is only for humans skimming logs.

The number’s first digit is its class. There are five:

  • 1xx informational: the request is in progress.
  • 2xx success: done, here is the result.
  • 3xx redirection: look at this other URL instead.
  • 4xx client error: the request itself was wrong.
  • 5xx server error: something broke on the server.

The class is what infrastructure layers act on; the specific code is what application clients code against. Two downstream consumers make that division concrete.

Alerting and paging. Observability platforms key error budgets and on-call rules off the 4xx/5xx split: a 5xx means the server broke, which is the on-call engineer’s job, while a 4xx means the client sent something bad, a client bug or a product issue, not an emergency. Return 500 Internal Server Error every time a user mistypes their email, and your team gets paged for client-side noise.

Retry policies. HTTP clients, the browser’s fetch, server-side SDKs, and load balancers have built-in retry rules that read the class. Most retry the 502/503/504 trio and 429 but leave 4xx alone. Pick the wrong code and the client either retries forever on what should have been a hard failure, or never retries on what was only a transient blip.

1xx Informational Niche, rarely sent
2xx Success Happy path, every read
3xx Redirection Browser follows Location
4xx Client error Doesn't page on-call
5xx Server error Pages on-call
The five HTTP status classes, the first digit of every response code.

The status codes you’ll actually send, by class

Section titled “The status codes you’ll actually send, by class”

Most of the 60-odd registered codes are historical. What follows is the working subset, grouped by class so you reason from the class down to the code.

You’ll rarely send a 1xx yourself, but three are worth knowing on sight.

  • 100 Continue: the client asks “may I send the body?” first, so a large request can learn about a rejection without sending megabytes.
  • 101 Switching Protocols: the WebSocket upgrade handshake. Open a WebSocket in DevTools and this is what the upgrade response shows.
  • 103 Early Hints: “while I work on the real response, here are resources to preload.” The CDN preload signal, which Cloudflare and Fastly cache and replay, and Next.js 16 can emit as Link headers.

The four codes you’ll send constantly:

  • 200 OK: a read or update that returns a body. The default for almost every successful response.
  • 201 Created: resource creation. Pair it with a Location header pointing at the new resource, so the client gets the URL to GET it without fishing the ID out of the body. Location and 201 travel together.
  • 202 Accepted: the request was accepted and queued, but the work hasn’t happened yet. Use it for “trigger an export” or “send the email later,” anything handed to a background job.
  • 204 No Content: a successful mutation the client needs no body for. Common for DELETE and for PATCHes whose result the client doesn’t display.

A redirect varies on two axes: permanent or temporary, and whether it preserves the request method or invites the client to change it. Those axes give you four codes.

  • 301 Moved Permanently and 308 Permanent Redirect: both permanent. 308 preserves the method, so a POST stays a POST, while 301 historically allowed clients to rewrite POST to GET. The default for new APIs is 308.
  • 302 Found and 307 Temporary Redirect: both temporary, with the same split. 307 preserves the method; 302 does not, because browsers historically rewrite POST to GET on it. The default is 307.
  • 303 See Other: explicitly tells the client to use GET on the next request. This is the classic Post-Redirect-Get pattern: a form POST returns 303 with a Location header, the browser follows with a GET, and the result page is reachable on refresh without resubmitting.
  • 304 Not Modified: the response to a conditional request when the resource hasn’t changed. The body is omitted and the client reuses its cached copy.

The two you’ll actually code for look like this on the wire.

HTTP/3 303 See Other
Location: /invoices/42

After a form submission. The client follows with GET /invoices/42, so the method is not preserved. Use this when a POST should land the user on a detail or confirmation page that’s safe to refresh.

The 4xx class is the longest and the place where most production confusion lives: nine codes, plus three distinctions that decide the ambiguous cases.

  • 400 Bad Request: the request is malformed and the server couldn’t parse it. Wrong JSON, wrong Content-Type, or missing required headers. It never made it past parsing.
  • 401 Unauthorized: unauthenticated. No credentials, expired credentials, or a bad token. The name is a misnomer: it really means “Unauthenticated.” Pair it with a WWW-Authenticate header to name the auth scheme you expect.
  • 403 Forbidden: authenticated but not allowed. The credentials are valid; the action is not. Wrong role, paywall, or missing permission.
  • 404 Not Found: resource not found, either because it doesn’t exist or because the current user isn’t allowed to know it does. (More on this tenancy nuance below.)
  • 405 Method Not Allowed: the path exists but the method doesn’t. GET /api/widgets works, DELETE /api/widgets doesn’t. Next.js route handlers send this automatically when a method isn’t exported.
  • 409 Conflict: a state conflict. A unique-constraint violation, an optimistic-concurrency mismatch (If-Match failed), or any case where the resource isn’t in the state your request assumes.
  • 410 Gone: the resource was here and is permanently gone. Use sparingly; 404 usually suffices.
  • 422 Unprocessable Content: parsed cleanly but failed validation. The JSON is well-formed and the fields are the right types, but a business rule rejected it (endDate < startDate, an invalid email, a taken slug). The validation-error code.
  • 429 Too Many Requests: rate limited. Pair with Retry-After to tell the client when to come back.

400 vs 422, parse versus validation. The mistake juniors ship most often. 400 means the server couldn’t parse the request; 422 means it parsed cleanly but a business rule rejected the value. The test is whether safeParse got to run: if parsing failed before Zod saw the value it’s 400, if safeParse returned { success: false } it’s 422.

401 vs 403, identity versus permission. 401 says “I don’t know who you are”; 403 says “I know who you are, and you can’t do this.” The test is whether re-signing-in would fix it. An expired session token is 401; a signed-in Viewer calling DELETE /invoices/42 when only Admins can delete is 403.

403 vs 404, the tenancy-hiding rule. In a multi-tenant app, when a signed-in user asks for a resource that belongs to a different organization, the senior default is 404, not 403. A 403 confirms the resource exists, leaking its existence to someone who shouldn’t know it’s there; a 404 gives away nothing. The multi-tenancy unit builds a tenantDb factory that enforces this at the query layer, so you can’t leak across orgs by accident.

Sort each request into the status code the server should return. Drag each item into the bucket it belongs to, then press Check.

400 Bad Request Server couldn't parse the request
422 Unprocessable Content Parsed cleanly, failed validation
403 Forbidden Authenticated but not allowed
Request body is not even json {{.
Request Content-Type is text/plain but the route expects JSON.
Body parses fine; endDate is before startDate.
Body parses fine; email field is 'not-an-email'.
Signed-in Viewer tries to call DELETE /invoices/42; only Admins can delete.
Signed-in user on a free plan calls an enterprise-only endpoint.

The 5xx subset is smaller: five codes, with one trio that travels together.

  • 500 Internal Server Error: the catch-all bug. An unhandled exception bubbled up to the framework boundary. This is the one your error tracker (Sentry) groups by stack trace.
  • 502 Bad Gateway: an upstream service returned garbage, often the proxy or load balancer reporting that the origin app crashed.
  • 503 Service Unavailable: the server is overloaded or down for maintenance. Pair with Retry-After if you can.
  • 504 Gateway Timeout: an upstream took too long to respond. The classic timeout-at-the-CDN response.
  • 507 Insufficient Storage: the write failed because the storage backend is full.

The 502/503/504 trio is the load-balancer trio: when you see them, the bug is usually not in your application code but in the layer in front of it, the CDN, the proxy, or origin reachability.

This is the 4xx/5xx split as one contract: 4xx is the client’s fault and does not page on-call; 5xx is the server’s fault and does. A dashboard that pages on 4xx spikes is miscalibrated; one that stays silent on 5xx spikes is missing real outages.

A POST handler hits an unhandled TypeError from a missing optional chain. The exception bubbles to the framework boundary. What status code should the client see?

400 Bad Request
422 Unprocessable Content
500 Internal Server Error
503 Service Unavailable

An API that doesn’t pick a standard error-body shape ends up inventing its own: { error: "..." } here, { message: "...", code: 42 } there, { errors: [{ field, msg }] } somewhere else. Clients then special-case each one, SDK generators can’t infer the shape, and switching vendors means rewriting every error-handling branch.

The standard to converge on is RFC 9457 Problem Details , an IETF specification that defines a JSON shape with five named fields and a content type, application/problem+json, that signals the body follows the standard. It supersedes RFC 7807 (2016) and stays backward-compatible with it; use 9457 for any new surface.

A real Problem Details response on the wire looks like this.

HTTP/3 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "The invoice could not be created. See errors for details.",
"instance": "/api/invoices",
"errors": [
{ "path": "dueDate", "message": "Date must be after today." },
{ "path": "lines.0.amount", "message": "Must be positive." }
]
}

Five core fields plus one extension:

{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "The invoice could not be created. See errors for details.",
"instance": "/api/invoices",
"errors": [
{ "path": "dueDate", "message": "Date must be after today." },
{ "path": "lines.0.amount", "message": "Must be positive." }
]
}

type is a URI identifying the kind of problem, and the field the client switches on, not the human-readable title or status. It’s the version-stable contract: you can change the title or detail text without breaking clients.

{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "The invoice could not be created. See errors for details.",
"instance": "/api/invoices",
"errors": [
{ "path": "dueDate", "message": "Date must be after today." },
{ "path": "lines.0.amount", "message": "Must be positive." }
]
}

title is a short, human-readable summary of the problem type. It does not change between occurrences: 'Validation failed', not 'Validation failed for invoice 42'. The per-occurrence text goes in the next field.

{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "The invoice could not be created. See errors for details.",
"instance": "/api/invoices",
"errors": [
{ "path": "dueDate", "message": "Date must be after today." },
{ "path": "lines.0.amount", "message": "Must be positive." }
]
}

status repeats the HTTP status code from the response line, and the two must match. The RFC marks the field as advisory, but mismatching it confuses middleware that re-emits the body and any tool that reads one without the other. Set both in one place server-side so they can’t drift.

{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "The invoice could not be created. See errors for details.",
"instance": "/api/invoices",
"errors": [
{ "path": "dueDate", "message": "Date must be after today." },
{ "path": "lines.0.amount", "message": "Must be positive." }
]
}

detail is a human-readable, occurrence-specific explanation. A generic error UI may surface this string verbatim, so keep it safe to show the user.

{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "The invoice could not be created. See errors for details.",
"instance": "/api/invoices",
"errors": [
{ "path": "dueDate", "message": "Date must be after today." },
{ "path": "lines.0.amount", "message": "Must be positive." }
]
}

instance is a URI identifying this specific occurrence, usually the request path. Useful for correlating a log line or support ticket with a server-side trace.

1 / 1

RFC 9457 also allows fields beyond the core five. Any such field is a problem-type-specific extension: once you pick a shape for a given type URI you’re committed, because clients code against it. The canonical one here is errors, the array of { path, message } above. It carries the same shape as a Zod issue array, so turning a safeParse failure into a Problem Details body is a one-line map.

One detail is easy to miss: the content type is application/problem+json, not the generic application/json. Middleware, observability tools, and SDK generators key off it to know the body follows RFC 9457. Get it wrong and the body is still readable JSON, but tooling won’t recognize the shape.

You won’t write a route handler in this lesson, but the call shape is worth seeing now so it’s familiar when you meet the helper.

src/app/api/invoices/route.ts
export async function POST(request: NextRequest) {
const parsed = createInvoiceSchema.safeParse(await request.json());
if (!parsed.success) {
return problemResponse({
type: 'https://api.example.com/problems/validation-failed',
title: 'Validation failed',
status: 422,
detail: 'The invoice could not be created.',
errors: parsed.error.issues.map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
});
}
// …happy path: insert, return 201 with Location.
}

One safeParse at the boundary, one problemResponse on the failure branch that derives the status line and the body’s status field from a single argument. The happy path returns 201 Created with the Location header this lesson already covered; wiring the full route handler comes when you build the CRUD endpoints.

Each claim exercises a distinction from this lesson — the 4xx confusion pairs, the body-line coherence rule, or the on-call paging contract. Mark each statement True or False.

A request body that fails Zod validation should return 422 Unprocessable Content, not 400 Bad Request.

400 means the server couldn’t parse the request — malformed JSON, wrong content type. 422 is for a body that parsed cleanly but failed business validation. The test: did safeParse get to run? If it did and returned { success: false }, the code is 422.

A signed-in user requesting a resource that belongs to another organization should receive 403 Forbidden so they know the resource exists but isn’t theirs.

403 leaks existence — it confirms the resource is real. The senior default for cross-tenant access is 404, indistinguishable from a missing resource. Enforce this at the query layer so you can’t leak by accident.

When the server crashes from a bug in your route handler, the right status code is 503 Service Unavailable.

503 is for capacity or availability problems — the server is healthy enough to say “I’m overloaded.” An unhandled exception is 500, the catch-all server bug. Sentry groups 500s by stack trace; 503s are what the load balancer emits during a deploy.

The status field inside a Problem Details body and the HTTP status code on the response line should always match.

The body field is advisory by the RFC, but mismatching it confuses middleware that re-emits the body and any client that reads one without the other. Set them in one place server-side so they can’t drift.

If your team’s alerting rules page on-call for 4xx spikes, the rules are miscalibrated.

4xx is the client’s fault. A 4xx spike means clients are sending bad requests — a client bug or a product issue, not an on-call emergency. The on-call paging signal is the 5xx rate.