REST API Design
Original work by ECC. Credit to ECC for the ideas and base guide.
Use this guide to make REST APIs that are clear, safe, and easy to use.
When to Use This Skill
Use this skill when you:
- Add a new API route.
- Review an API contract.
- Add pages, filters, search, or sort.
- Add error handling.
- Plan API versions.
- Build a public or partner API.
Start With the Contract
Before you write code, list:
- The resource and URL.
- The request fields.
- The response fields.
- The status codes.
- The access rules.
- The page and sort rules.
- The rate limit.
- The errors a client may get.
Keep names and response shapes the same across all routes.
Resource Design
URL Rules
Use plural nouns. Use lowercase words. Join words with hyphens.
GET /api/v1/users
GET /api/v1/users/:id
POST /api/v1/users
PUT /api/v1/users/:id
PATCH /api/v1/users/:id
DELETE /api/v1/users/:idUse a nested URL when one resource belongs to another:
GET /api/v1/users/:id/orders
POST /api/v1/users/:id/ordersKeep nesting short. Do not go past two resource levels. Use a top-level route if the child has its own ID:
GET /api/v1/orders/:idUse an action route only when normal create, read, update, and delete rules do not fit:
POST /api/v1/orders/:id/cancel
POST /api/v1/auth/login
POST /api/v1/auth/refreshGood and Bad Names
# Good
/api/v1/team-members
/api/v1/orders?status=active
/api/v1/users/123/orders
# Bad
/api/v1/getUsers
/api/v1/user
/api/v1/team_members
/api/v1/users/123/getOrdersUse one field style in JSON. This guide uses snake_case.
HTTP Methods
| Method | Safe to retry | Changes data | Use |
|---|---|---|---|
| GET | Yes | No | Read data |
| POST | Usually no | Yes | Create data or run an action |
| PUT | Yes | Yes | Replace all fields |
| PATCH | It depends | Yes | Change some fields |
| DELETE | Yes | Yes | Delete data |
A retry is safe only when the same call has the same final result. A PATCH that adds 1 each time is not safe. A PATCH that sets a name is safe.
For POST calls that may be retried, support an idempotency key:
Idempotency-Key: 7bb1c5c0-52a1-4f18-8af8-55ebf85e13fdStore the key with the first result for a set time. Return that result for a repeat call.
Status Codes
Success
200 OK GET, PUT, or PATCH with a body
201 Created A new resource was made
202 Accepted Work will finish later
204 No Content Success with no bodyFor 201 Created, add a Location header:
HTTP/1.1 201 Created
Location: /api/v1/users/abc-123For 202 Accepted, return a way to check the work:
{
"data": {
"job_id": "job-123",
"status": "pending",
"status_url": "/api/v1/jobs/job-123"
}
}Do not send a body with 204 No Content.
Client Errors
400 Bad Request Bad JSON or bad request form
401 Unauthorized Login data is missing or not valid
403 Forbidden The caller is known but lacks access
404 Not Found The resource does not exist
405 Method Not Allowed The HTTP method is not allowed
409 Conflict A duplicate or state clash happened
412 Precondition Failed A write used an old version
415 Unsupported Media Type The body type is not allowed
422 Unprocessable Content The JSON is valid but its values are not
429 Too Many Requests The rate limit was passedUse either 400 or 422 for field checks. Pick one rule and use it on every route.
A private API may return 404 instead of 403 when this helps hide whether a resource exists.
Server Errors
500 Internal Server Error An unknown server fault
502 Bad Gateway A service used by this API failed
503 Service Unavailable A short outage or heavy load
504 Gateway Timeout A service used by this API took too longAdd Retry-After to 429 and short-term 503 replies when you know when to retry.
Do not show stack traces, SQL, file paths, keys, tokens, or private data.
Response Shapes
One Resource
{
"data": {
"id": "abc-123",
"email": "alice@example.com",
"name": "Alice",
"created_at": "2025-01-15T10:30:00Z"
}
}Use ISO 8601 dates in UTC:
2025-01-15T10:30:00ZUse strings for IDs. This avoids number size bugs in some tools.
A List
{
"data": [
{ "id": "abc-123", "name": "Alice" },
{ "id": "def-456", "name": "Bob" }
],
"meta": {
"total": 142,
"page": 1,
"per_page": 20,
"total_pages": 8
},
"links": {
"self": "/api/v1/users?page=1&per_page=20",
"next": "/api/v1/users?page=2&per_page=20",
"last": "/api/v1/users?page=8&per_page=20"
}
}Return an empty list as []. Do not use null.
An Error
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"request_id": "req-789",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"code": "invalid_format"
}
]
}
}Rules for errors:
- Keep
codeshort and stable. - Make
messagesafe for users. - Put field errors in
details. - Add a request ID for support.
- Never put secrets in an error.
- Do not make clients read the message to learn the error type.
Choose one response style for the whole API:
interface ApiResponse<T> {
data: T;
meta?: PaginationMeta;
links?: PaginationLinks;
}
interface ApiError {
error: {
code: string;
message: string;
request_id?: string;
details?: FieldError[];
};
}Page Rules
Always use a fixed sort. Add a unique last sort field, such as id. Without this, rows may move or repeat.
Set a default and a hard limit:
Default limit: 20
Max limit: 100Reject bad values, such as a negative page or a limit over the max.
Page and Offset
GET /api/v1/users?page=2&per_page=20SELECT *
FROM users
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;Use this for small lists and screens that need page numbers. Large offsets can be slow. New rows can also cause repeats or missed rows.
Cursor
GET /api/v1/users?cursor=opaque-value&limit=20Read one extra row to learn if another page exists:
SELECT *
FROM users
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 21;{
"data": [],
"meta": {
"has_next": true,
"next_cursor": "opaque-value"
}
}The cursor must hold all sort fields. Sign or lock it so clients cannot change it. Treat it as a secret value, but do not place private data inside it.
Return 400 Bad Request for a bad or old cursor.
Use cursor pages for feeds, large lists, and public APIs. Cursor pages do not support a direct jump to page 50.
Filters, Sort, and Search
Only allow known fields and actions. Never place raw client input into SQL.
Filters
GET /api/v1/orders?status=active&customer_id=abc-123
GET /api/v1/products?price[gte]=10&price[lte]=100
GET /api/v1/orders?created_at[after]=2025-01-01
GET /api/v1/products?category=electronics,clothing
GET /api/v1/orders?customer.country=USState how commas, spaces, empty values, and letter case work. URL encode all special signs.
Sort
GET /api/v1/products?sort=-created_at
GET /api/v1/products?sort=-featured,price,-created_atA leading - means high to low. Add id as the last sort field when the client leaves it out.
Reject unknown sort fields.
Search
GET /api/v1/products?q=wireless%20headphones
GET /api/v1/users?email=aliceSet a max search length. State if search ignores letter case. Escape search text before use.
Pick Fields
GET /api/v1/users?fields=id,name,email
GET /api/v1/orders?fields=id,total,status&include=customer.nameAllow only safe fields. Do not let fields expose passwords, tokens, private notes, or hidden admin data.
Set a limit on linked data in include. This stops very large replies and slow calls.
Request Checks
Check these before work starts:
- The body is valid JSON.
Content-Typeis allowed.- Required fields exist.
- Text and list sizes are within limits.
- Values have the right type and range.
- IDs have the right form.
- Unknown fields follow one clear rule.
- The caller has access to each resource.
Reject very large bodies before parsing them.
Do not trust client fields such as user_id, role, price, or is_admin. Get trusted values from the login data or server records.
For PATCH, state how null works. It may clear a field, or it may be banned. Do not leave this unclear.
Login and Access
Use TLS for every API call.
GET /api/v1/users
Authorization: Bearer token-valueFor service calls:
GET /api/v1/data
X-API-Key: key-valueDo not place keys or tokens in URLs. URLs may be saved in logs and browser history.
Check both login and access:
app.get("/api/v1/orders/:id", async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({
error: { code: "not_found", message: "Order not found" }
});
}
if (order.userId !== req.user.id) {
return res.status(403).json({
error: { code: "forbidden", message: "Access denied" }
});
}
return res.json({ data: order });
});Check access on every object. Do not rely only on a user ID from the URL.
Safe Updates
Two users may edit the same item at once. Use a version value or ETag to stop lost updates:
GET /api/v1/users/abc-123
ETag: "v7"PATCH /api/v1/users/abc-123
If-Match: "v7"
Content-Type: application/jsonReturn 412 Precondition Failed if the item changed after the client read it.
For delete calls, decide if the item is fully removed or only marked as deleted. State this in the API guide.
Rate Limits
Rate limits must use a clear key, such as IP, user, API key, or service.
HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 95
RateLimit-Reset: 60When the limit is passed:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json{
"error": {
"code": "rate_limit_exceeded",
"message": "Too many requests. Try again in 60 seconds."
}
}These are sample limits. Pick limits from real load tests and user needs:
| Group | Sample limit | Count by |
|---|---|---|
| Guest | 30 each minute | IP |
| Signed in | 100 each minute | User |
| Paid | 1,000 each minute | API key |
| Internal | 10,000 each minute | Service |
Set lower limits on costly routes, login tries, password reset, search, and file upload.
Caching
Use cache rules only when they are safe.
Cache-Control: private, max-age=60
ETag: "user-abc-123-v7"Use Cache-Control: no-store for secrets and other private data that must not be saved.
Add all fields that change the reply to the cache key. This may include the user, query, language, and API version.
Never let one user receive another user's cached data.
API Versions
Path versions are clear and easy to test:
/api/v1/users
/api/v2/usersStart with /api/v1. Make a new major version only for a change that can break a client.
Changes that often do not need a new version:
- Add a new route.
- Add an optional request field.
- Add a response field, if clients are told to ignore unknown fields.
- Add an optional query field.
Changes that need a new version:
- Remove or rename a field.
- Change a field type.
- Change a field from optional to required.
- Change the meaning of a field.
- Change a URL.
- Change login rules.
- Change the default sort in a way that breaks page use.
Keep no more than two active major versions when you can. Give public API users at least six months of notice when possible.
Send clear end dates:
Deprecation: true
Sunset: Thu, 01 Jan 2026 00:00:00 GMT
Link: <https://api.example.com/migrate-v2>; rel="deprecation"After the end date, return 410 Gone only if the route is truly removed.
Concrete Example
Task: Add an endpoint that creates a user.
Contract:
POST /api/v1/users
Content-Type: application/json
Idempotency-Key: 7bb1c5c0-52a1-4f18-8af8-55ebf85e13fdRequest:
{
"email": "alice@example.com",
"name": "Alice"
}Success:
HTTP/1.1 201 Created
Location: /api/v1/users/abc-123
Content-Type: application/json{
"data": {
"id": "abc-123",
"email": "alice@example.com",
"name": "Alice",
"created_at": "2025-01-15T10:30:00Z"
}
}Bad email:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"code": "invalid_format"
}
]
}
}Email already used:
HTTP/1.1 409 Conflict
Content-Type: application/json{
"error": {
"code": "email_in_use",
"message": "That email address is already in use"
}
}TypeScript route:
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";
const createUserSchema = z.object({
email: z.string().email().max(254),
name: z.string().trim().min(1).max(100),
}).strict();
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{
error: {
code: "bad_json",
message: "The request body must be valid JSON",
},
},
{ status: 400 }
);
}
const parsed = createUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: {
code: "validation_error",
message: "Request validation failed",
details: parsed.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
code: issue.code,
})),
},
},
{ status: 422 }
);
}
const found = await findUserByEmail(parsed.data.email);
if (found) {
return NextResponse.json(
{
error: {
code: "email_in_use",
message: "That email address is already in use",
},
},
{ status: 409 }
);
}
const user = await createUser(parsed.data);
return NextResponse.json(
{ data: user },
{
status: 201,
headers: {
Location: `/api/v1/users/${user.id}`,
},
}
);
}In real code, also add login checks, rate limits, body size limits, request IDs, and idempotency storage.
Review List
Before you ship a route, check:
- The URL uses plural nouns.
- The HTTP method fits the work.
- Each result has the right status code.
- Request fields have size and type checks.
- Login and access checks are present.
- Errors use one stable shape.
- Secrets never appear in URLs or errors.
- Lists have limits and a fixed sort.
- Filters and sort fields use allowlists.
- Writes handle retries when needed.
- Updates guard against lost changes.
- Rate limits are set.
- Cache rules do not leak private data.
- Breaking changes use a new version.
- The API contract and examples are up to date.