CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/api-design-patterns

REST API design patterns — response envelopes, pagination, filtering, status codes, and resource naming

87

1.78x
Quality

83%

Does it follow best practices?

Impact

98%

1.78x

Average score across 4 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
api-design-patterns
description:
REST API design patterns — response envelopes, pagination, filtering, status codes, resource naming, and PATCH semantics. Apply AUTOMATICALLY whenever building any REST API endpoints.
keywords:
rest api, api design, response envelope, pagination, cursor pagination, filtering, sorting, status codes, resource naming, patch, partial update, bulk operations, api versioning, query parameters, rest conventions
license:
MIT

API Design Patterns

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.


1. Response Envelope

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" }
    ]
  }
}

Rules

  • Success responses: { "data": ... } — the data field holds the resource or array of resources.
  • Error responses: { "error": { "code": "...", "message": "..." } } — machine-readable code, human-readable message.
  • Never mix error and data in the same response. A response has data OR error, never both.
  • For paginated responses, pagination metadata sits alongside data (see Pagination section).

2. Pagination

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)
    }
  });
});

Rules

  • Default limit: 20 items. Maximum limit: 100. Always clamp the client-provided limit.
  • Cursor-based pagination is preferred for large datasets (better performance, no skipping issues).
  • Offset-based pagination is acceptable for smaller datasets where page numbers are needed.
  • Always include pagination metadata alongside data in the response.
  • Never return unbounded lists. Even if the dataset is small today, it will grow.

3. Filtering and Sorting

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-01

WRONG — 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 ...
});

Rules

  • Whitelist allowed sort fields and filter parameters. Never interpolate raw query values into SQL.
  • Provide safe defaults: sort by created_at DESC, page 1, limit 20.
  • Use consistent conventions across all endpoints (same sort syntax, same filter parameter style).
  • Filter server-side. Never return all records and expect the client to filter.

4. HTTP Status Codes

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 409

RIGHT — 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
});

Quick reference

StatusWhen to use
200 OKSuccessful GET, PUT, PATCH
201 CreatedSuccessful POST that creates a resource
204 No ContentSuccessful DELETE (no response body)
400 Bad RequestMalformed request syntax, invalid JSON
404 Not FoundResource does not exist
409 ConflictDuplicate resource or state conflict
422 Unprocessable EntityValid JSON but fails business validation
500 Internal Server ErrorUnexpected server failure

5. Resource Naming

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 product

RIGHT — 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

Rules

  • Use plural nouns for collection endpoints: /products, /orders, /users.
  • Never put verbs in URLs. The HTTP method IS the verb.
  • Nest resources only one level deep. /customers/42/orders is good. /customers/42/orders/7/items/3/reviews is too deep — flatten it to /order-items/3/reviews.
  • Use kebab-case for multi-word resource names: /order-items, /shipping-addresses.
  • Keep resource names consistent across the API.

6. Partial Updates (PATCH Semantics)

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 });
});

Rules

  • PATCH = partial update. Only modify fields present in the request body.
  • PUT = full replacement. Requires all fields. Use PATCH for partial updates, not PUT.
  • Whitelist updatable fields. Never blindly spread req.body into an UPDATE query.
  • Return the full updated resource in the response so the client has the current state.

7. Bulk Operations

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
  });
});

Rules

  • Set a maximum batch size (e.g., 100 items) and enforce it.
  • Report per-item success/failure so the client knows which items succeeded.
  • Use 201 if all succeeded, 207 (Multi-Status) if partial success, 422 if all failed.

8. Versioning

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/products
const 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);

Rules

  • Start with /api/v1/ from day one. Adding versioning later is painful.
  • URL-prefix versioning (/api/v1/) is preferred over header versioning for simplicity and discoverability.
  • Keep previous versions running during migration periods.

Checklist

  • All success responses use { "data": ... } envelope
  • All error responses use { "error": { "code": "...", "message": "..." } } envelope
  • List endpoints support pagination (cursor-based or offset-based)
  • Pagination has a default limit (20) and maximum limit (100)
  • Filtering uses whitelisted query parameters, not raw interpolation
  • Sorting uses consistent conventions with safe defaults
  • POST returns 201, DELETE returns 204, validation errors return 422, conflicts return 409
  • Resource URLs use plural nouns with no verbs
  • Nested resources are at most one level deep
  • PATCH endpoints only update provided fields (true partial update)
  • PATCH returns the full updated resource
  • Bulk endpoints enforce a maximum batch size
  • API paths include a version prefix (/api/v1/)

Verifiers

  • response-envelope — Consistent response envelope for all API endpoints
  • pagination-support — Pagination support for list endpoints
  • correct-status-codes — Correct HTTP status codes for each operation type
  • resource-naming — RESTful resource naming conventions
  • patch-semantics — Proper PATCH partial update semantics
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/api-design-patterns badge