REST APIs
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: The dominant style of web API — treat URLs as “things” (resources) and HTTP verbs as “actions” (GET to read, POST to create, PUT to update, DELETE to remove).
In plain English
REST stands for Representational State Transfer, a name only a computer scientist could love. The idea is much simpler than the name:
When you design an API, structure it around resources (the nouns your system manages) and use HTTP methods (the standard verbs the internet already speaks) to act on them. Forget custom verbs in URLs. Forget calling functions across the wire. Just URLs + methods.
Examples:
| URL | Method | Means |
|---|---|---|
/users | GET | Get a list of all users |
/users/123 | GET | Get the user with id 123 |
/users | POST | Create a new user |
/users/123 | PUT | Replace the user with id 123 |
/users/123 | PATCH | Update specific fields of user 123 |
/users/123 | DELETE | Delete user 123 |
/users/123/posts | GET | Get all posts BY user 123 |
/users/123/posts | POST | Create a new post BY user 123 |
The pattern is “URL identifies what; method says what to do with it.” Combined with JSON for payloads and standard HTTP status codes for outcomes, this convention forms the backbone of the modern web’s API layer.
Most APIs you’ll encounter — the GitHub API, Stripe API, Supabase’s auto-generated REST API, your own Next.js API routes — are REST-shaped, even when they don’t perfectly follow Roy Fielding’s original definition.
Why it matters
Three concrete payoffs of using REST conventions:
-
It’s already familiar. Anyone who’s used the web has used REST without knowing it. New developers can read REST URLs and guess what endpoints do. Onboarding is fast.
-
Tools assume it. Browsers, caches, CDNs, monitoring tools, security scanners — all designed around HTTP methods.
GETis automatically cacheable;DELETEis automatically blocked from<a>tags. Following REST lets the entire HTTP ecosystem help you. -
It scales conceptually. Once you internalize “URL = resource, method = action,” every new API endpoint slots into a predictable pattern. Designers spend less time inventing URL schemes.
The trade-off: REST is loose. Two REST APIs can be “RESTful” and still feel completely different. There’s no enforced schema. The Stripe API and the GitHub API are both REST but have different conventions for pagination, errors, naming, auth. Every REST API has its quirks.
The HTTP methods, more carefully
Each HTTP method has documented semantics. Most APIs follow them, but some violate them. Knowing the intent helps you use methods correctly and spot misuse.
| Method | Purpose | Safe? | Idempotent? | Body? |
|---|---|---|---|---|
| GET | Retrieve a resource | âś… | âś… | Usually no |
| HEAD | Like GET, but only return headers | âś… | âś… | No |
| OPTIONS | List allowed methods (CORS uses this) | âś… | âś… | No |
| POST | Create a resource OR perform an action | ❌ | ❌ | Yes |
| PUT | Replace a resource entirely | ❌ | ✅ | Yes |
| PATCH | Partially update a resource | ❌ | ❌ (technically not required, but often is) | Yes |
| DELETE | Remove a resource | ❌ | ✅ | Optional |
Safe = the method shouldn’t change state. Browsers may pre-fetch GET URLs for speed; they would not pre-fetch POST.
Idempotent = calling the method N times has the same effect as calling it once. DELETE /users/123 twice still ends with user 123 deleted. POST /users twice creates two users.
These properties matter for retries, caching, and CDN behavior. A non-idempotent operation should never be retried automatically without an idempotency key (see APIs overview).
Status codes — the response language
HTTP defines categories of status codes. REST APIs use them to communicate outcome:
| Range | Meaning | Common codes |
|---|---|---|
| 2xx | Success | 200 OK, 201 Created, 204 No Content |
| 3xx | Redirection | 301 Moved Permanently, 302 Found, 304 Not Modified |
| 4xx | Client error (your fault) | 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity, 429 Too Many Requests |
| 5xx | Server error (our fault) | 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout |
A well-designed REST API uses these precisely:
- 200 OK — request succeeded, response body has the result.
- 201 Created — POST succeeded; usually includes a
Locationheader pointing to the new resource and the resource body. - 204 No Content — request succeeded, no body to return (common for DELETE).
- 400 Bad Request — the request is malformed (bad JSON, missing field).
- 401 Unauthorized — you haven’t authenticated (despite the name).
- 403 Forbidden — you’re authenticated but not allowed.
- 404 Not Found — the resource doesn’t exist.
- 409 Conflict — request conflicts with current state (e.g. duplicate username).
- 422 Unprocessable Entity — request was syntactically valid but semantically wrong (most validation errors).
- 429 Too Many Requests — rate limit hit.
- 500 Internal Server Error — unexpected backend bug.
- 503 Service Unavailable — backend overloaded or down.
The line between 400 and 422 is fuzzy — many APIs use 400 for both. The line between 401 and 403 matters: don’t return 403 to anonymous users when you mean “log in first” — that’s 401.
A concrete example: a small REST API for blog posts
GET /api/posts → 200 [post, post, ...]
POST /api/posts → 201 { id, title, ... }
GET /api/posts/47 → 200 { id, title, body, ... } (404 if missing)
PATCH /api/posts/47 → 200 { ...updated post } (404 if missing)
DELETE /api/posts/47 → 204 (empty body) (404 if missing)
GET /api/posts/47/comments → 200 [comment, comment, ...]
POST /api/posts/47/comments → 201 { id, body, ... }
In Next.js, that translates to a folder structure under app/api/posts/:
app/api/posts/route.ts → GET (list) + POST (create)
app/api/posts/[id]/route.ts → GET (one) + PATCH + DELETE
app/api/posts/[id]/comments/route.ts → GET (list) + POST (create)
Each route.ts exports functions named after HTTP methods:
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
const post = await db.posts.findById(params.id);
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(post);
}
const UpdatePostBody = z.object({
title: z.string().min(1).optional(),
body: z.string().optional(),
});
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
const data = UpdatePostBody.parse(await req.json());
const updated = await db.posts.update(params.id, data);
return NextResponse.json(updated);
}
export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
await db.posts.delete(params.id);
return new NextResponse(null, { status: 204 });
}Pattern: method → validate input → check auth → do the work → return appropriate status + body.
Naming conventions
Most well-designed REST APIs follow these:
- Plural nouns for collections —
/usersnot/user. - Lowercase, hyphenated URLs —
/user-profilesnot/UserProfilesor/user_profiles. - Hierarchy reflects ownership —
/users/123/posts(posts that belong to user 123). - Query strings for filtering, sorting, pagination —
/posts?status=published&sort=-created_at&page=2. - Avoid verbs in URLs — NOT
/getUser/123or/createUser. The verb is the HTTP method.
The verb-in-URL anti-pattern is the most common REST violation. POST /users/123/activate is technically not RESTful — but it’s pragmatic enough that nobody complains.
Pagination, filtering, sorting — the messy details
REST has no standard for these. Each API picks a style. The common patterns:
Pagination
| Style | Example | Pros | Cons |
|---|---|---|---|
| Page + size | ?page=2&size=20 | Simple | Inconsistent when underlying data changes |
| Offset + limit | ?offset=20&limit=20 | Simple | Same problem as page+size |
| Cursor-based | ?cursor=abc123&limit=20 | Stable across data changes | Can’t jump to a specific page |
| Link headers | Link: <...?page=3>; rel="next" | Self-discoverable | Less obvious |
For large or volatile collections, cursor-based pagination is the right answer. For small static lists, page+size is fine.
Filtering
?status=published&author_id=42&tag=javascript
Or, for complex queries, some APIs use a JSON body even on GET (unusual but seen) or a dedicated /search endpoint.
Sorting
?sort=created_at (ascending) or ?sort=-created_at (descending) — the leading hyphen for descending is a common convention.
Multiple: ?sort=-created_at,title.
Including related data
?include=author,comments — expand certain relations inline (saves round trips). Or use sparse fieldsets: ?fields=title,body (return only specific columns).
Errors — give the caller something to work with
A bad REST error response: 500 { "error": "Something went wrong" }.
A good one:
{
"error": {
"code": "invalid_field",
"message": "Title is required",
"field": "title",
"request_id": "req_abc123"
}
}The discipline: a stable code (machine-readable), a human message, the offending field if applicable, and a request_id so debugging is possible across logs.
For multi-error validation cases:
{
"errors": [
{ "code": "required", "field": "title", "message": "Title is required" },
{ "code": "too_long", "field": "body", "message": "Body cannot exceed 10000 characters" }
]
}The exact shape is a choice; consistency across your API is what matters.
Versioning a REST API
The classic options:
| Strategy | Example | Notes |
|---|---|---|
| URL versioning | /v1/users, /v2/users | Most obvious, most disruptive |
| Header versioning | Accept-Version: v2 | Cleaner URLs, hidden complexity |
| Query param | /users?api-version=2 | Common in legacy/Azure APIs |
| Date-based | Stripe-Version: 2024-01-01 | Best for evolving APIs (Stripe’s choice) |
| No versioning, backwards-compat additions | /users forever, just add new fields | Hardest to do right, best UX when done right |
For projects George is likely to build (and APIs he’ll consume), assume the rule is: never break a published endpoint. Add new fields, never remove or rename them. New behavior gets new endpoints or new optional parameters.
Caching — the underrated REST superpower
GET requests are designed to be cacheable. Browsers, CDNs, and reverse proxies all cache based on the URL + headers. Good REST APIs use this aggressively:
Cache-Control: public, max-age=300, s-maxage=600
ETag: "abc123"
Last-Modified: Wed, 19 Jun 2026 12:00:00 GMTThe next request can include If-None-Match: "abc123" or If-Modified-Since: ...; the server returns 304 Not Modified with no body, saving bandwidth.
For server-side data that changes rarely, this is a huge win. See CDNs for the broader caching layer.
REST vs RPC vs GraphQL — when to pick which
In broad strokes:
| Style | Best for |
|---|---|
| REST | Resource-oriented data, public APIs, mobile apps, sites that benefit from HTTP caching |
| GraphQL | Heavily relational data, mobile apps wanting to minimize over-fetch, schemas you can introspect |
| gRPC | Backend-to-backend communication, performance-critical paths, strongly-typed teams |
| tRPC / Server Actions | Internal first-party APIs within a TypeScript codebase — typed end-to-end |
For first-party APIs in a Next.js project, the modern choice is often server actions or tRPC, not REST — because the typing reaches the client. For third-party-facing APIs (other developers will integrate), REST or GraphQL still dominate.
Common gotchas
-
PUTvsPATCHis easy to misuse.PUTis a full replacement: missing fields become null.PATCHupdates only fields you send. Mixing them up is a classic bug. -
POSTfor both create AND “do an action”. Strictly RESTful: create is POST to a collection (/users). But “run a calculation,” “send an email,” “approve an order” — these don’t map cleanly to CRUD. POST is the universal escape hatch. That’s pragmatic, but document it clearly. -
401vs403.401means “I don’t know who you are; authenticate.”403means “I know who you are; you can’t do this.” Many APIs confuse these. -
404ambiguity. “User 123 doesn’t exist” vs “this endpoint doesn’t exist” are both 404. Surface enough error info to distinguish. -
GETwith side effects is a contract violation. AGET /users/123/deleteendpoint is asking for trouble — browsers and crawlers might GET it speculatively. Side effects go in POST/PATCH/DELETE. -
Verb in URL is a smell, not always wrong.
POST /orders/47/cancelis technically un-REST but very common. The alternative (PATCH /orders/47 { "status": "cancelled" }) loses some intent. Pragmatism wins. -
Inconsistent collection vs item endpoints.
/usersand/user/123(singular/plural mix). Pick one convention. -
Trailing slashes matter (sometimes). Some servers redirect
/users/to/users; others 404. Pick a convention and document it. -
Content negotiation isn’t free. Allowing
Accept: application/xmlvsapplication/jsondoubles your test surface. Most modern APIs are JSON-only. -
Date formats. ISO 8601 (
2026-06-19T12:00:00Z) is the only sensible choice. Unix timestamps creep in occasionally; epoch milliseconds vs seconds is a common confusion. -
Booleans in query strings are tricky.
?active=true,?active=1,?active=yes— depends on the framework. Be consistent. -
Empty collections vs not-yet-existing collections.
GET /users/123/posts→ 200 with[](user exists, has no posts) vs 404 (user 123 doesn’t exist). Both are valid; pick one. -
Bulk operations don’t fit REST cleanly. Updating 100 records is awkward —
PATCH /userswith a body of changes? Each API has its own bulk convention; none are particularly clean. -
HATEOAS is the part of REST nobody implements. Roy Fielding’s original REST included links between resources for navigation. Almost no modern REST API does this rigorously. The pragmatic version (1-2 link fields) is fine; full HATEOAS is rare.
-
Implicit type coercion in query params is a footgun. Everything in
?id=123&active=trueis a string. Coerce explicitly. -
Large request bodies need streaming. A 100MB upload via
POSTshould stream, not be loaded into memory. Most frameworks handle this; verify yours does. -
CORS preflights are real and surprising. Cross-origin
POSTwith JSON content type triggers anOPTIONSpreflight before the actual request. Your CORS middleware must handle both. -
PUTis idempotent in theory; many APIs make it not. IfPUT /users/123auto-creates if missing (and that auto-creation includes generating an id internally), it’s no longer idempotent. Document carefully. -
The “DELETE” status code question. Should DELETE return 204 (no body)? 200 (with the deleted resource as confirmation)? Both are used. Pick one.
-
Don’t mix REST styles within one API. If
/postsuses page+size pagination, don’t make/commentsuse cursor-based. Consistency matters more than purity. -
REST doesn’t enforce typing. A request
POST /users { "age": "twenty" }reaches your handler; YOU validate it. Use Zod (or equivalent) at every entry point. -
Mocking REST in tests is essential. Tools like MSW intercept fetch calls and return canned responses. Don’t hit the real API in unit tests.
See also
- APIs — the big picture 🟩 — REST is one family among several
- What is a backend? đźź©
- GraphQL 🟥 — the main REST alternative
- Webhooks 🟩 — REST endpoints that get called by external services
- HTTP 🟥 — the underlying protocol
- Server actions (Next.js) 🟥 — typed alternative for first-party APIs
- CDNs 🟩 — HTTP caching layer
- OWASP Top 10 🟩 🟦 — API-specific security threats
- JWT 🟩 — REST auth patterns
- Glossary: REST, HTTP, Endpoint, Status code
Sources
- Roy Fielding — REST dissertation, Ch. 5 — the foundational paper
- MDN — HTTP Methods
- MDN — HTTP Status Codes
- Stripe API design guide — canonical REST done well
- GitHub REST API docs
- Microsoft REST API Guidelines — comprehensive practical guide
- Next.js — Route handlers