REST API design patterns — response envelopes, pagination, filtering, status codes, and resource naming
87
83%
Does it follow best practices?
Impact
98%
1.78xAverage score across 4 eval scenarios
Passed
No known issues
Every REST API needs these conventions from the start. Do not wait to be asked — apply these patterns automatically whenever you create or modify API endpoints.
These patterns are as fundamental as input validation. An API without consistent response shapes, proper status codes, and pagination is broken by default. Apply all of them proactively.
Wrap every response in a consistent envelope so clients always know where to find the data and how to detect errors.
WRONG — returning raw data with no wrapper:
[
{ "id": 1, "name": "Widget" },
{ "id": 2, "name": "Gadget" }
]RIGHT — consistent envelope for success:
{
"data": [
{ "id": 1, "name": "Widget" },
{ "id": 2, "name": "Gadget" }
]
}RIGHT — consistent envelope for a single resource:
{
"data": { "id": 1, "name": "Widget", "price": 9.99 }
}WRONG — inconsistent error shapes across endpoints:
{ "msg": "not found" }
{ "error": "Validation failed", "fields": ["name"] }
{ "message": "Something went wrong", "code": 500 }RIGHT — consistent error envelope with code and message:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Name is required",
"details": [
{ "field": "name", "issue": "must not be empty" }
]
}
}{ "data": ... } — the data field holds the resource or array of resources.{ "error": { "code": "...", "message": "..." } } — machine-readable code, human-readable message.data OR error, never both.data (see Pagination section).Any endpoint that returns a list of resources MUST support pagination. Unbounded list queries will eventually crash or timeout as data grows.
WRONG — returning all records with no pagination:
app.get("/api/products", async (req, res) => {
const products = await db.query("SELECT * FROM products");
res.json({ data: products }); // Returns 50,000 rows
});RIGHT — cursor-based pagination with consistent response shape:
app.get("/api/products", async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined;
let query = "SELECT * FROM products";
const params: any[] = [];
if (cursor) {
query += " WHERE id > ?";
params.push(cursor);
}
query += " ORDER BY id ASC LIMIT ?";
params.push(limit + 1); // Fetch one extra to detect next page
const rows = await db.query(query, params);
const hasMore = rows.length > limit;
const products = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? products[products.length - 1].id : null;
res.json({
data: products,
pagination: {
next_cursor: nextCursor,
has_more: hasMore,
limit
}
});
});RIGHT — offset-based pagination (simpler, acceptable for small datasets):
app.get("/api/products", async (req, res) => {
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const [products, totalCount] = await Promise.all([
db.query("SELECT * FROM products ORDER BY id LIMIT ? OFFSET ?", [limit, offset]),
db.query("SELECT COUNT(*) as count FROM products"),
]);
res.json({
data: products,
pagination: {
page,
limit,
total: totalCount[0].count,
total_pages: Math.ceil(totalCount[0].count / limit)
}
});
});data in the response.Provide query parameter conventions for filtering and sorting so clients can retrieve exactly the data they need.
WRONG — no filtering, forcing clients to fetch everything and filter client-side:
GET /api/orders (returns ALL orders, client filters in JS)RIGHT — server-side filtering via query parameters:
GET /api/orders?status=active&customer_id=42&created_after=2025-01-01WRONG — inconsistent or unsafe sort parameters:
GET /api/products?sort=price (ascending? descending? unclear)
GET /api/products?orderBy=name&dir=up (non-standard conventions)RIGHT — consistent sort convention with direction prefix:
GET /api/products?sort=price_asc
GET /api/products?sort=created_at_desc
GET /api/products?sort=-price (alternative: - prefix for descending)app.get("/api/products", async (req, res) => {
const allowedSortFields = ["name", "price", "created_at"];
const allowedFilters = ["category", "min_price", "max_price", "in_stock"];
// Parse sort — default to created_at descending
let sortField = "created_at";
let sortDir = "DESC";
if (req.query.sort) {
const sort = req.query.sort as string;
const descending = sort.startsWith("-");
const field = descending ? sort.slice(1) : sort;
if (allowedSortFields.includes(field)) {
sortField = field;
sortDir = descending ? "DESC" : "ASC";
}
}
// Build WHERE clauses from allowed filters only
const conditions: string[] = [];
const params: any[] = [];
if (req.query.category) {
conditions.push("category = ?");
params.push(req.query.category);
}
if (req.query.min_price) {
conditions.push("price >= ?");
params.push(parseFloat(req.query.min_price as string));
}
if (req.query.max_price) {
conditions.push("price <= ?");
params.push(parseFloat(req.query.max_price as string));
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `SELECT * FROM products ${where} ORDER BY ${sortField} ${sortDir} LIMIT ? OFFSET ?`;
// ... pagination logic ...
});created_at DESC, page 1, limit 20.Use the correct status code for each operation. Status codes tell clients what happened without parsing the body.
WRONG — using 200 for everything:
// Creating a resource
res.status(200).json({ data: newProduct }); // Should be 201
// Deleting a resource
res.status(200).json({ message: "deleted" }); // Should be 204
// Validation error
res.status(400).json({ error: "Invalid" }); // 422 is more specific
// Duplicate resource
res.status(400).json({ error: "Already exists" }); // Should be 409RIGHT — correct status codes for each operation:
// 201 Created — resource was created successfully
app.post("/api/products", async (req, res) => {
const product = await createProduct(req.body);
res.status(201).json({ data: product });
});
// 200 OK — resource retrieved or updated successfully
app.get("/api/products/:id", async (req, res) => {
const product = await getProduct(req.params.id);
if (!product) {
return res.status(404).json({
error: { code: "NOT_FOUND", message: "Product not found" }
});
}
res.json({ data: product });
});
// 204 No Content — resource deleted, no body needed
app.delete("/api/products/:id", async (req, res) => {
const deleted = await deleteProduct(req.params.id);
if (!deleted) {
return res.status(404).json({
error: { code: "NOT_FOUND", message: "Product not found" }
});
}
res.status(204).send();
});
// 409 Conflict — resource already exists or state conflict
app.post("/api/products", async (req, res) => {
const existing = await findBySlug(req.body.slug);
if (existing) {
return res.status(409).json({
error: { code: "CONFLICT", message: "A product with this slug already exists" }
});
}
// ... create product
});
// 422 Unprocessable Entity — validation errors
app.post("/api/products", async (req, res) => {
const errors = validateProduct(req.body);
if (errors.length > 0) {
return res.status(422).json({
error: {
code: "VALIDATION_ERROR",
message: "Request body failed validation",
details: errors
}
});
}
// ... create product
});| Status | When to use |
|---|---|
200 OK | Successful GET, PUT, PATCH |
201 Created | Successful POST that creates a resource |
204 No Content | Successful DELETE (no response body) |
400 Bad Request | Malformed request syntax, invalid JSON |
404 Not Found | Resource does not exist |
409 Conflict | Duplicate resource or state conflict |
422 Unprocessable Entity | Valid JSON but fails business validation |
500 Internal Server Error | Unexpected server failure |
URL paths should be predictable and consistent. Follow REST conventions so consumers can guess the endpoints.
WRONG — verbs in URLs, inconsistent pluralization:
POST /api/createProduct
GET /api/getProducts
PUT /api/updateProduct/42
DELETE /api/product/42/remove
GET /api/user/42/order (singular, inconsistent)RIGHT — plural nouns, HTTP method conveys the action:
GET /api/products — list products
POST /api/products — create a product
GET /api/products/42 — get a single product
PUT /api/products/42 — replace a product
PATCH /api/products/42 — partially update a product
DELETE /api/products/42 — delete a productRIGHT — nested resources for parent-child relationships:
GET /api/customers/42/orders — list orders for customer 42
POST /api/customers/42/orders — create an order for customer 42
GET /api/customers/42/orders/7 — get order 7 for customer 42/products, /orders, /users./customers/42/orders is good. /customers/42/orders/7/items/3/reviews is too deep — flatten it to /order-items/3/reviews./order-items, /shipping-addresses.PATCH endpoints should update only the fields provided in the request body, leaving all other fields unchanged.
WRONG — requiring all fields for an update (this is PUT behavior on a PATCH route):
app.patch("/api/products/:id", async (req, res) => {
// Overwrites ALL fields, even ones not sent — nullifies missing fields
const { name, price, description, category } = req.body;
await db.query(
"UPDATE products SET name = ?, price = ?, description = ?, category = ? WHERE id = ?",
[name, price, description, category, req.params.id]
);
// If client only sent { price: 19.99 }, name/description/category become NULL
});RIGHT — only update fields that were actually provided:
app.patch("/api/products/:id", async (req, res) => {
const allowedFields = ["name", "price", "description", "category"];
const updates: string[] = [];
const values: any[] = [];
for (const field of allowedFields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (updates.length === 0) {
return res.status(422).json({
error: { code: "VALIDATION_ERROR", message: "No valid fields to update" }
});
}
values.push(req.params.id);
await db.query(
`UPDATE products SET ${updates.join(", ")} WHERE id = ?`,
values
);
const updated = await db.query("SELECT * FROM products WHERE id = ?", [req.params.id]);
res.json({ data: updated });
});req.body into an UPDATE query.When clients need to create or update many resources at once, provide batch endpoints rather than forcing N individual requests.
WRONG — client must loop and send one request per item:
// Client sends 100 individual POST requests
for (const product of products) {
await fetch("/api/products", { method: "POST", body: JSON.stringify(product) });
}RIGHT — batch endpoint that accepts an array:
app.post("/api/products/batch", async (req, res) => {
const { items } = req.body;
if (!Array.isArray(items) || items.length === 0) {
return res.status(422).json({
error: { code: "VALIDATION_ERROR", message: "items must be a non-empty array" }
});
}
if (items.length > 100) {
return res.status(422).json({
error: { code: "VALIDATION_ERROR", message: "Maximum 100 items per batch" }
});
}
const results = [];
const errors = [];
for (let i = 0; i < items.length; i++) {
try {
const validationErrors = validateProduct(items[i]);
if (validationErrors.length > 0) {
errors.push({ index: i, error: validationErrors });
continue;
}
const product = await createProduct(items[i]);
results.push({ index: i, data: product });
} catch (err) {
errors.push({ index: i, error: "Internal error" });
}
}
const status = errors.length === 0 ? 201 : (results.length === 0 ? 422 : 207);
res.status(status).json({
data: results,
errors: errors.length > 0 ? errors : undefined
});
});Plan for API versioning from the start. URL-prefix versioning is the simplest and most widely adopted approach.
RIGHT — URL-prefix versioning:
GET /api/v1/products
GET /api/v2/productsconst v1Router = express.Router();
const v2Router = express.Router();
v1Router.get("/products", getProductsV1);
v2Router.get("/products", getProductsV2);
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);/api/v1/ from day one. Adding versioning later is painful./api/v1/) is preferred over header versioning for simplicity and discoverability.{ "data": ... } envelope{ "error": { "code": "...", "message": "..." } } envelope/api/v1/)