API
REST
Node.js
HTTP
Backend
Architecture

REST API Design: Status Codes, Versioning, and Pagination

Design a REST API people can use: resource naming, the status codes that matter, error response shape, cursor pagination, and versioning without breakage.

11 min read
Chamikara Nayanajith

Most REST APIs are not badly designed so much as inconsistently designed. One endpoint returns { data: [...] } and the next returns a bare array. Errors are a string here and an object there. One route uses userId, another uses user_id. Every one of those is defensible on its own, and together they mean every consumer writes special cases.

The decisions below are the ones worth making once, writing down, and then not revisiting per endpoint.

REST API design starts with resources and URLs

A URL names a thing. The HTTP method says what you are doing to it. If the verb is in the path, something has gone wrong.

bash
# Verbs in the path. The method is now decorative.
POST /getUser
POST /createOrder
POST /updateOrderStatus

# The method carries the verb.
GET    /users/42
POST   /orders
PATCH  /orders/981

Plural nouns for collections, consistently. /users/42 reads as "item 42 in the users collection", which is what it is. Mixing /user/42 and /orders in one API means nobody can guess a URL without checking the docs.

Nest one level to express ownership, and stop there:

bash
GET /users/42/orders          # orders belonging to user 42
GET /orders/981               # a specific order, addressed directly

# Three levels deep. Nothing is gained and the URL is now fragile.
GET /users/42/orders/981/items/3/discounts

Deep nesting encodes a hierarchy that will change. Once an order can belong to an organisation as well as a user, every one of those URLs is wrong. Address things by their own ID and use query parameters for filtering.

Status codes

Roughly ten codes cover almost everything. The failure mode is not using too few, it is returning 200 with an error in the body, which breaks every client that checks the status.

CodeUse for
200Successful GET, PATCH or PUT with a body
201Resource created. Include a Location header
204Success with nothing to return, typically DELETE
400Malformed request the client can fix
401Not authenticated, or credentials expired
403Authenticated but not permitted
404No such resource
409Conflict with current state, such as a duplicate email
422Well-formed but semantically invalid
429Rate limited
500You broke it. Never blame the client with a 5xx

The 400 versus 422 line is the one people argue about. My rule: 400 if the request could not be parsed or a required field is missing, 422 if it parsed fine but the values are unacceptable. Pick one interpretation and apply it everywhere; consistency matters more than which side you land on.

Two of those codes have enough behind them to be worth their own treatment: 401 and 403 hinge on how you issue and check credentials, covered in JWT authentication in Node.js, and 429 has a whole design behind it in API rate limiting.

One error shape, everywhere

Clients write one error handler if you let them. Give every failure the same structure regardless of which layer produced it.

typescript
type ApiError = {
  error: {
    // Stable, machine-readable. Clients branch on this, not on the message.
    code: string;
    // Human-readable. Safe to log, not intended for end users.
    message: string;
    // Field-level detail for validation failures.
    details?: { field: string; message: string }[];
    // Correlates a user's screenshot with your logs.
    requestId: string;
  };
};

// 422
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The request body failed validation",
    "details": [
      { "field": "email", "message": "Must be a valid email address" },
      { "field": "age", "message": "Must be at least 18" }
    ],
    "requestId": "req_01HQ8XZ"
  }
}

The code field is what makes this useful. Message text gets reworded, translated and improved, and any client matching on it breaks silently when you do. A stable code survives all of that.

The requestId costs nothing and repays itself the first time a user reports a problem. Generate it in middleware, attach it to every log line for that request, and return it on errors.

API error handling: never leak the stack trace

typescript
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
  const requestId = req.id;

  if (err instanceof ApiClientError) {
    return res.status(err.status).json({
      error: { code: err.code, message: err.message, details: err.details, requestId },
    });
  }

  // Unexpected: log everything, return nothing.
  logger.error({ err, requestId }, 'Unhandled error');
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'Something went wrong',
      requestId,
    },
  });
});

A stack trace in a 500 response tells an attacker your framework versions, your file paths, and often your database structure. Log it, return the request ID.

Pagination

Every collection endpoint needs it from day one. Adding it later is a breaking change, because clients written against an unpaginated endpoint assume they received everything.

Offset pagination

bash
GET /orders?page=3&limit=50

Simple, supports jumping to a page, and degrades badly. It gets slower the deeper you go, because the database still walks the skipped rows. And it skips or duplicates items when the underlying data changes between requests: a new order arriving pushes everything down one, so the last row of page 1 becomes the first row of page 2 and the user sees it twice.

Cursor pagination

typescript
// GET /orders?limit=50&cursor=eyJpZCI6Ijk4MSJ9
const cursor = req.query.cursor
  ? JSON.parse(Buffer.from(req.query.cursor as string, 'base64url').toString())
  : null;

const rows = await db.order.findMany({
  where: cursor ? { id: { lt: cursor.id } } : undefined,
  orderBy: { id: 'desc' },
  take: limit + 1, // one extra to detect whether more exist
});

const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, limit) : rows;

res.json({
  items,
  nextCursor: hasMore
    ? Buffer.from(JSON.stringify({ id: items.at(-1)!.id })).toString('base64url')
    : null,
});

Stable under inserts, and the query cost does not grow with depth. The tradeoff is that you cannot jump to page 47. For feeds, activity logs and anything infinite-scrolling, that is not a real loss. Fetching the extra row is how you know whether nextCursor should be null without a second count query.

Encode the cursor as opaque base64 even though it is trivially decodable. It signals to clients that the format is not a contract, which leaves you free to change it from an ID to a composite key later.

Versioning

Two options are worth considering, and one of them is usually better than people expect.

URL versioning, /v1/orders, is visible, trivially cacheable and easy to route. It is what most public APIs use. The downside is that a version bump duplicates every endpoint, so v2 tends to arrive with a big-bang migration.

Header versioning, via Accept or a custom header, keeps URLs stable and lets you version individual resources. It is harder to test by hand, easy to forget, and caching proxies need to be told to vary on it.

The option people skip: do not version at all, and make only additive changes. Adding a field is not breaking. Adding an optional parameter is not breaking. If you can hold that line, you never pay the migration cost. Version when you genuinely must remove or rename something, and expect that to be rarer than it sounds.

Idempotency for writes

A client sends POST /payments, the connection drops before the response arrives, and the client retries. Did the first one go through? Without idempotency the honest answer is that nobody knows, and the customer may have been charged twice.

typescript
export async function createPayment(req: Request, res: Response) {
  const key = req.header('Idempotency-Key');
  if (!key) {
    return res.status(400).json({
      error: { code: 'IDEMPOTENCY_KEY_REQUIRED', message: 'Idempotency-Key header is required' },
    });
  }

  const existing = await db.idempotencyRecord.findUnique({ where: { key } });
  if (existing) {
    // Replay the original response rather than doing the work again.
    return res.status(existing.status).json(existing.body);
  }

  const payment = await chargeCard(req.body);
  const body = { id: payment.id, status: payment.status };

  await db.idempotencyRecord.create({
    data: { key, status: 201, body, expiresAt: addHours(new Date(), 24) },
  });

  res.status(201).json(body);
}

The client generates the key, usually a UUID, and reuses it across retries of the same logical operation. GET, PUT and DELETE are idempotent by definition; POST and PATCH are the ones that need this. Any endpoint that moves money or sends a message should have it.

Validate at the boundary

Everything arriving from a client is untrusted, including from your own frontend. Validate once, at the edge, and let typed data flow inward. Parsing the body with a schema rather than checking fields by hand also gives you the TypeScript types for free, which I go through in validating API input with Zod.

Large responses

One more thing that is cheaper to decide early. An endpoint that can return a very large body, a CSV export or a full audit log, should stream rather than build the whole response in memory. Otherwise a single big export takes the process down, and the failure scales with your most successful customer. The mechanics are in Node.js streams and backpressure.

What I would settle before writing endpoint two

Plural nouns, one nesting level, the status code table above, one error shape with a stable code and a request ID, cursor pagination on every collection, and a written rule for what counts as breaking. None of these are hard. They are just much cheaper to decide now than to retrofit across forty endpoints and three clients.

The two I would not skip under time pressure are the error shape and pagination, because both are breaking changes to add later and both are invisible until the API has consumers.

Frequently asked questions

When should I use 400 versus 422?

Use 400 when the request could not be parsed or a required field is missing, and 422 when it parsed correctly but the values are unacceptable. The distinction is debated and either reading is defensible; what matters is picking one and applying it across every endpoint, because inconsistency is what forces clients to write special cases.

Should I use offset or cursor pagination?

Cursor pagination for anything that grows, such as feeds and activity logs. Offset pagination gets slower the deeper you go because the database still walks skipped rows, and it duplicates or skips items when data changes between requests. Offset is only worth it when users genuinely need to jump to an arbitrary page number.

How should I version a REST API?

Consider not versioning at all and making only additive changes, since adding a field or an optional parameter is not breaking. When you must break something, URL versioning like /v1/orders is visible, cacheable and easy to route, which is why most public APIs use it. Header versioning keeps URLs stable but is easier to forget and harder to test by hand.

What should an API error response look like?

One shape for every failure, containing a stable machine-readable code, a human-readable message, optional field-level details for validation errors, and a request ID. Clients should branch on the code, never on the message text, because messages get reworded and translated. Never return a stack trace, which exposes framework versions and file paths.

Related Articles