DEV Community

Cover image for REST API Design Best Practices: A Field Guide for Backend Developers
Bry
Bry

Posted on • Originally published at Medium

REST API Design Best Practices: A Field Guide for Backend Developers

Key Points

  • Name resources with nouns and plural forms (/users, /orders/{id}), never verbs (/getUser, /createOrder).
  • Match HTTP methods to their RFC-defined semantics — GET is safe and idempotent, POST is neither, PATCH is partial update.
  • Return RFC 7807 Problem Details for all error responses — a consistent, machine-readable error shape your consumers can rely on.
  • Use URL path versioning (/v1/) — it is visible, cacheable, and debuggable without header inspection.
  • Cursor-based pagination scales to any dataset size; offset-based pagination breaks under concurrent writes.

Introduction

An API that ignores HTTP semantics, uses verbs in URLs, and returns { "error": "something went wrong" } is technically functional. It also generates a steady stream of support tickets, misconfigured clients, and integration bugs that consume engineering time for as long as the API exists.

REST is not a standard — it is a set of architectural constraints described by Roy Fielding in 2000. Most APIs that call themselves RESTful violate several of those constraints and still work fine. The practices in this article are not about philosophical purity. They are about making choices that reduce friction for the developers consuming your API, reduce bugs at integration points, and reduce maintenance burden over a long API lifetime.

I've shipped and maintained REST APIs across enough companies to know that the decisions you make in week one are the ones you're still apologizing for in year three. This article is the set of defaults I'd apply if I were starting a new API today — with a reference implementation that shows all of them working together.


Architecture Overview

Architecture Overview

Diagram: Request flow from client through API Gateway, token validation, business logic, and data layer.


Resource Design: Naming and Hierarchy

Resources are the nouns in your API. Every URL identifies a resource, not an action.

Rule: Use plural nouns for collections, singular for specific members.

GET    /v1/articles          → list all articles
POST   /v1/articles          → create an article
GET    /v1/articles/{id}     → get one article
PATCH  /v1/articles/{id}     → partially update one article
DELETE /v1/articles/{id}     → delete one article
Enter fullscreen mode Exit fullscreen mode

Never use verbs in paths:

❌ /v1/getArticle
❌ /v1/createArticle
❌ /v1/articles/delete
✓  /v1/articles/{id}   with DELETE method
Enter fullscreen mode Exit fullscreen mode

For sub-resources, express ownership through hierarchy — but stop at two levels. Deeper than two levels signals the resource model needs rethinking.

GET /v1/articles/{id}/comments      ← acceptable
GET /v1/articles/{id}/comments/{cid}/replies/{rid}  ← too deep, model differently
Enter fullscreen mode Exit fullscreen mode

Resource Data Model

Resource Data Model


HTTP Methods and Status Codes

Method Semantics

Method Safe Idempotent Body When to use
GET Yes Yes No Fetch a resource or collection. Never mutate.
POST No No Yes Create a new resource. Each call may produce a new resource.
PUT No Yes Yes Replace a resource completely. Must send the full representation.
PATCH No No Yes Partial update. Send only the fields to change.
DELETE No Yes No Remove a resource. Idempotent — deleting twice is not an error.

Safe means the method must not change server state.

Idempotent means calling it N times has the same effect as calling it once.

Use PATCH over PUT for updates unless you genuinely need full replacement. Requiring clients to send the entire resource for a title change is wasteful and error-prone.

Status Codes That Matter

Code Meaning When
200 OK Success with body GET, PATCH, PUT responses
201 Created Resource created POST success — include Location header
204 No Content Success, no body DELETE success
400 Bad Request Malformed request Missing required field, invalid format
401 Unauthorized Not authenticated No token or invalid token
403 Forbidden Authenticated but not allowed Correct token, wrong permissions
404 Not Found Resource does not exist ID not in database
409 Conflict State conflict Duplicate create, optimistic lock failure
422 Unprocessable Entity Valid format, invalid content Passes JSON parse but fails business validation
429 Too Many Requests Rate limit hit Include Retry-After header
500 Internal Server Error Server bug Never expose stack traces

Do not return 200 OK with an error payload. A client that checks the status code first — which all good clients do — will miss your error.

Request-Response Sequence

Request Response Sequence


Response Shape: Data and Errors

Success Response

Keep success responses flat when possible. Wrap in a data envelope only when you need top-level metadata alongside the resource (e.g., pagination).

// Single resource  no wrapper needed
{
  "id": "abc123",
  "title": "REST API Design Best Practices",
  "authorId": "usr_789",
  "status": "published",
  "createdAt": "2026-10-01T09:00:00Z",
  "updatedAt": "2026-10-01T09:00:00Z"
}

// Collection  wrapper needed for pagination metadata
{
  "data": [...],
  "pagination": {
    "limit": 20,
    "next_cursor": "abc124"
  }
}
Enter fullscreen mode Exit fullscreen mode

Use ISO 8601 for all timestamps with UTC timezone (Z suffix). Never return Unix epoch integers — they require client-side conversion and obscure the value during debugging.

Error Response — RFC 7807 Problem Details

RFC 7807 defines a standard error response format. Use it. Your consumers can write error handling once and it works across all your endpoints.

{
  "type": "https://api.example.com/errors/not-found",
  "title": "Article Not Found",
  "status": 404,
  "detail": "No article with id 'abc999' exists.",
  "instance": "/v1/articles/abc999"
}
Enter fullscreen mode Exit fullscreen mode

Set Content-Type: application/problem+json on all error responses.

import { Response } from 'express';

function problem(
  res: Response,
  status: number,
  title: string,
  detail: string,
  instance?: string,
): void {
  const body: Record<string, unknown> = {
    type: `https://api.example.com/errors/${status}`,
    title,
    status,
    detail,
  };
  if (instance) body.instance = instance;
  res.status(status).contentType('application/problem+json').json(body);
}

// Usage
problem(res, 404, 'Article Not Found', `No article with id '${id}' exists.`, req.path);
Enter fullscreen mode Exit fullscreen mode

Versioning

You will change your API. Version it from day one.

Strategy Example Pros Cons
URL path /v1/articles Visible, cacheable, loggable, bookmarkable URL "leaks" version — semantic purists object
Request header API-Version: 2026-01-01 Clean URLs Invisible, hard to test in browser, can't cache differently
Query param /articles?version=1 Easy to test Pollutes query space, gets stripped by some proxies
Accept header Accept: application/vnd.api.v2+json RFC-compliant content negotiation Verbose, poor tooling support

Use URL path versioning. You'll hear that it violates REST semantics — typically from people who've never had to track down a versioning bug in production logs where the client was silently sending the wrong header. Visibility in logs, cache keys, and browser testability are real, daily benefits. The semantic purity objection is theoretical.

State Diagram: API Version Lifecycle

State Diagram: API Version Lifecycle

Announce deprecation in response headers: Deprecation: true, Sunset: Sat, 01 Jan 2028 00:00:00 GMT.


Pagination and Filtering

Cursor vs Offset

Criteria Offset (?page=2&limit=20) Cursor (?cursor=abc&limit=20)
Performance at scale Degrades — OFFSET 1000000 is slow Constant — indexed seek
Stable under concurrent writes No — items shift between pages Yes — cursor anchors position
Seekable to arbitrary page Yes No — must walk forward
Implementation complexity Low Medium

Use cursor pagination. OFFSET 100000 is a slow table scan dressed up as a query parameter. I've seen this exact pattern take down a read replica during a traffic spike — the query plan looked fine in development on 5,000 rows, then became catastrophic at 2 million under load. Offset pagination is fine for 50-row config lists that never change. For anything that grows or updates frequently, use cursor pagination from day one. Retrofitting it later is painful.

app.get('/v1/articles', (req: Request, res: Response) => {
  const limit = Math.min(parseInt((req.query.limit as string) ?? '20', 10), 100);
  const cursor = req.query.cursor as string | undefined;
  const authorId = req.query.author_id as string | undefined;
  const status = req.query.status as string | undefined;

  let items = Array.from(db.values());
  if (authorId) items = items.filter(a => a.authorId === authorId);
  if (status)   items = items.filter(a => a.status === status);

  let start = 0;
  if (cursor) {
    const idx = items.findIndex(a => a.id === cursor);
    if (idx !== -1) start = idx + 1;
  }

  const page = items.slice(start, start + limit);
  const nextCursor = page.length === limit ? page[page.length - 1].id : null;

  res.json({ data: page, pagination: { limit, next_cursor: nextCursor } });
});
Enter fullscreen mode Exit fullscreen mode

Filtering goes in query parameters. Sorting too:

GET /v1/articles?status=published&author_id=usr_789&sort=-createdAt&limit=20
Enter fullscreen mode Exit fullscreen mode

Convention: prefix - means descending (-created_at = newest first).


Security Fundamentals

Authentication

Pass credentials in the Authorization header. Never in URLs — URLs appear in server logs, browser history, and proxy caches.

# Correct
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/articles

# Wrong — token in logs forever
curl https://api.example.com/v1/articles?token=$TOKEN
Enter fullscreen mode Exit fullscreen mode

Request Authentication Flow

Request Authentication Flow

HTTPS Only

Enforce HTTPS at the gateway level. Reject HTTP with 301 Moved Permanently to the HTTPS equivalent. Never accept credentials over plain HTTP.

Rate Limiting Response

When you rate-limit a request, return 429 Too Many Requests with these headers:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1727780460
Enter fullscreen mode Exit fullscreen mode

Idempotency Keys

POST is not idempotent — calling it twice creates two resources. Networks are unreliable. A client that retries a timed-out POST /v1/orders could create a duplicate charge.

The solution: clients attach a unique Idempotency-Key header. The server stores the response for that key. If the same key arrives again, return the cached response without re-executing the operation.

# First request — creates the article
curl -X POST https://api.example.com/v1/articles \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -H "Content-Type: application/json" \
  -d '{"title":"Hello","content":"...","authorId":"usr_1"}'
# → 201 Created

# Retry with same key (network timeout, client retries)
curl -X POST https://api.example.com/v1/articles \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  ...
# → 201 Created (same response, no second article created)
Enter fullscreen mode Exit fullscreen mode
// Middleware: check and store idempotency keys
// Use Redis with a TTL (e.g., 24 h) in production — Map is in-memory only
const idempotencyStore = new Map<string, { status: number; body: unknown }>();

function withIdempotency(
  handler: (req: Request, res: Response) => void,
): (req: Request, res: Response) => void {
  return (req, res) => {
    const key = req.headers['idempotency-key'] as string | undefined;
    if (key) {
      const cached = idempotencyStore.get(key);
      if (cached) {
        res.status(cached.status).json(cached.body);
        return;
      }
      const originalJson = res.json.bind(res);
      res.json = (body) => {
        idempotencyStore.set(key, { status: res.statusCode, body });
        return originalJson(body);
      };
    }
    handler(req, res);
  };
}

// Apply to any non-idempotent route
app.post('/v1/articles', withIdempotency((req, res) => {
  // ... normal create logic
}));
Enter fullscreen mode Exit fullscreen mode

Idempotency keys matter most for payment, order, and email-send endpoints. Stripe, PayPal, and Shopify all implement this pattern. Your key store needs a TTL — a key that lives forever leaks memory; 24 hours is the Stripe standard.


Common Mistakes

Mistake 1: Verbs in resource names

POST /v1/sendEmail treats the action as the resource. The resource is the email — the action is the HTTP method. POST /v1/emails is correct. If an action genuinely has no resource noun, use a sub-resource: POST /v1/articles/{id}/publish.

Mistake 2: Returning 200 OK for errors

Returning { "success": false, "error": "not found" } with 200 OK means clients must parse and inspect the body on every response to detect failures. HTTP status codes exist precisely to avoid this. Use them.

Mistake 3: Swallowing 404 vs 403

Returning 404 Not Found when the resource exists but the caller lacks access is both a correctness bug and a security vulnerability — you're leaking resource existence to unauthorized callers. The correct response is 403 Forbidden. Return 404 only when the resource genuinely does not exist (or when you deliberately want to hide its existence — which must be an intentional, documented choice, not an accident).

Mistake 4: Offset pagination in high-traffic collections

SELECT * FROM articles ORDER BY created_at LIMIT 20 OFFSET 10000 requires the database to read and discard 10,000 rows. I've debugged production incidents caused by exactly this — the query looks fine on 5,000 rows in development, then becomes a full table scan in disguise at 2 million rows under peak load. Switch to cursor-based pagination: WHERE id > $cursor ORDER BY id LIMIT 20.

Mistake 5: No consistent error shape

An API that returns { "message": "..." } on some errors, { "error": { "code": "...", "description": "..." } } on others, and a plain HTML 500 page on unhandled exceptions forces consumers to write separate error handling for every possible failure mode. Adopt RFC 7807 and return the same shape everywhere — including unhandled 500s.


Full Example: Complete Express TypeScript Implementation

Everything covered above in one runnable file. Copy it, run npm run dev, and use the curl commands below to verify each behavior.

import express, { Request, Response } from 'express';
import { z } from 'zod';
import { randomUUID } from 'crypto';

const app = express();
app.use(express.json());

// Types

interface Article {
  id: string;
  title: string;
  content: string;
  authorId: string;
  status: 'draft' | 'published';
  createdAt: string;
  updatedAt: string;
}

const db = new Map<string, Article>();

// Helpers

function nowIso(): string {
  return new Date().toISOString();
}

function problem(res: Response, status: number, title: string, detail: string, instance?: string): void {
  const body: Record<string, unknown> = {
    type: `https://api.example.com/errors/${status}`,
    title,
    status,
    detail,
  };
  if (instance) body.instance = instance;
  res.status(status).contentType('application/problem+json').json(body);
}

// Validation schemas

const ArticleCreateSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
  authorId: z.string().min(1),
});

const ArticleUpdateSchema = z.object({
  title: z.string().min(1).optional(),
  content: z.string().min(1).optional(),
});

// Routes

// POST /v1/articles — create, 201 + Location header
app.post('/v1/articles', (req: Request, res: Response) => {
  const parsed = ArticleCreateSchema.safeParse(req.body);
  if (!parsed.success) {
    problem(res, 422, 'Validation Error', parsed.error.issues.map(i => i.message).join(', '));
    return;
  }
  const id = randomUUID();
  const ts = nowIso();
  const article: Article = { id, ...parsed.data, status: 'draft', createdAt: ts, updatedAt: ts };
  db.set(id, article);
  res.status(201).setHeader('Location', `/v1/articles/${id}`).json(article);
});

// GET /v1/articles — list with cursor pagination and filtering
app.get('/v1/articles', (req: Request, res: Response) => {
  const limit = Math.min(parseInt((req.query.limit as string) ?? '20', 10), 100);
  const cursor   = req.query.cursor    as string | undefined;
  const authorId = req.query.author_id as string | undefined;
  const status   = req.query.status   as string | undefined;

  let items = Array.from(db.values());
  if (authorId) items = items.filter(a => a.authorId === authorId);
  if (status)   items = items.filter(a => a.status === status);

  let start = 0;
  if (cursor) {
    const idx = items.findIndex(a => a.id === cursor);
    if (idx !== -1) start = idx + 1;
  }

  const page = items.slice(start, start + limit);
  const nextCursor = page.length === limit ? page[page.length - 1].id : null;
  res.json({ data: page, pagination: { limit, next_cursor: nextCursor } });
});

// GET /v1/articles/:id
app.get('/v1/articles/:id', (req: Request, res: Response) => {
  const article = db.get(req.params.id);
  if (!article) { problem(res, 404, 'Article Not Found', `No article with id '${req.params.id}'.`, req.path); return; }
  res.json(article);
});

// PATCH /v1/articles/:id — partial update
app.patch('/v1/articles/:id', (req: Request, res: Response) => {
  const article = db.get(req.params.id);
  if (!article) { problem(res, 404, 'Article Not Found', `No article with id '${req.params.id}'.`, req.path); return; }

  const parsed = ArticleUpdateSchema.safeParse(req.body);
  if (!parsed.success) { problem(res, 422, 'Validation Error', parsed.error.issues.map(i => i.message).join(', ')); return; }

  if (parsed.data.title   !== undefined) article.title   = parsed.data.title;
  if (parsed.data.content !== undefined) article.content = parsed.data.content;
  article.updatedAt = nowIso();
  res.json(article);
});

// POST /v1/articles/:id/publish — action as sub-resource
app.post('/v1/articles/:id/publish', (req: Request, res: Response) => {
  const article = db.get(req.params.id);
  if (!article) { problem(res, 404, 'Article Not Found', `No article with id '${req.params.id}'.`, req.path); return; }
  if (article.status === 'published') { problem(res, 409, 'Already Published', `Article '${req.params.id}' is already published.`, req.path); return; }
  article.status = 'published';
  article.updatedAt = nowIso();
  res.json(article);
});

// DELETE /v1/articles/:id — 204 No Content
app.delete('/v1/articles/:id', (req: Request, res: Response) => {
  if (!db.has(req.params.id)) { problem(res, 404, 'Article Not Found', `No article with id '${req.params.id}'.`, req.path); return; }
  db.delete(req.params.id);
  res.status(204).send();
});

app.listen(3000, () => console.log('API running on http://localhost:3000'));
Enter fullscreen mode Exit fullscreen mode

Run it:

npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Test the full cycle:

# Create
curl -s -X POST http://localhost:3000/v1/articles \
  -H "Content-Type: application/json" \
  -d '{"title":"Hello","content":"World","authorId":"usr_1"}' | jq

# List with filter
curl -s "http://localhost:3000/v1/articles?author_id=usr_1&limit=5" | jq

# Publish (action as sub-resource)
curl -s -X POST http://localhost:3000/v1/articles/{id}/publish | jq

# Delete — expect 204
curl -s -o /dev/null -w "%{http_code}" -X DELETE http://localhost:3000/v1/articles/{id}
Enter fullscreen mode Exit fullscreen mode

Full source with tests: GitHub link


Conclusion

REST API design is a set of decisions you make once and live with for years. The ones I see cause the most pain aren't exotic — they're the basics: verbs in URLs, inconsistent error shapes, offset pagination on growing datasets, no versioning plan. None of this is new. RFC 7807 has existed since 2016. Cursor pagination predates it by decades.

The reason these mistakes keep appearing in fresh codebases is that nothing stops you from making them. Frameworks don't enforce it. Tutorials skip it. You have to choose these defaults deliberately.

Now you have a reason to.


Further Reading


If this helped, a like and a follow are appreciated — and if you've solved this differently, drop a comment, I'd like to hear it.

Bry Writes Code — cloud and API infrastructure specialist. Building or auditing a REST API? Get in touch.

Top comments (0)