DEV Community

Alex Chen
Alex Chen

Posted on

Designing a REST API That Developers Actually Like Using

Designing a REST API That Developers Actually Like Using

I've consumed hundreds of APIs. The good ones share these patterns. The bad ones make me want to quit coding.

1. Consistent Response Format

//  GOOD: Every response has the same structure
{
  "data": { ... },
  "meta": {
    "request_id": "req_abc123",
    "timestamp": "2026-05-16T01:00:00Z"
  }
}

//  BAD: Inconsistent formats
// Sometimes: { "user": { ... } }
// Other times: { "result": { ... } }
// Errors:     { "error": true, "message": "..." }
Enter fullscreen mode Exit fullscreen mode

Why: One parser handles every response. No guessing.

2. Pagination That Doesn't Suck

//  GOOD: Cursor-based pagination (for large datasets)
GET /api/users?limit=20&cursor=eyJpZCI6MTAwfQ

{
  "data": [...],
  "pagination": {
    "cursor": "eyJpZCI6MTIwfQ",    // Pass this for next page
    "has_more": true,
    "count": 20
  }
}

//  ALSO GOOD: Offset-based (for small datasets)
GET /api/users?page=2&limit=20

{
  "data": [...],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 347,
    "total_pages": 18
  }
}

//  BAD: No pagination at all
// Returns ALL records  crashes on large datasets
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: Cursor-based for >1000 records, offset-based for smaller sets.

3. Proper HTTP Status Codes

Code When to Use Example
200 Successful GET/PUT/PATCH {"data": user}
201 Resource created {"data": user, "location": "/users/123"}
204 Deleted successfully (empty body)
400 Bad request {"error": "email is invalid"}
401 Not authenticated {"error": "missing token"}
403 Authenticated but not allowed {"error": "admin only"}
404 Not found {"error": "user 999 not found"}
409 Conflict {"error": "email already registered"}
422 Validation failed {"errors": {"name": "required"}}
429 Rate limited {"retry_after": 60}
500 Server error {"error": "internal error", "request_id": "..."}

4. Filtering, Sorting, and Searching

# Filter: exact match
GET /api/posts?status=published&author_id=123

# Filter: range
GET /api/orders?created_from=2026-01-01&created_to=2026-05-16

# Sort: field and direction
GET /api/posts?sort=-created_at,title   # desc by date, then asc by title

# Search: full-text
GET /api/posts?q=nodejs+tutorial

# Field selection: reduce payload size
GET /api/users?fields=id,name,email

# Include related resources
GET /api/posts?include=author,tags,comments
Enter fullscreen mode Exit fullscreen mode

5. Version Your API

# URL versioning (most common)
/api/v1/users
/api/v2/users

# Header versioning (cleaner URLs)
Accept: application/vnd.myapp.v2+json
GET /api/users
Enter fullscreen mode Exit fullscreen mode

Support old versions for at least 6 months after releasing v2.

6. Error Responses Developers Can Use

//  USELESS error response
{
  "error": "Something went wrong"
}

//  HELPFUL error response
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "issue": "Invalid email format",
        "value": "not-an-email"
      },
      {
        "field": "password",
        "issue": "Must be at least 8 characters",
        "value": "[REDACTED]"
      }
    ],
    "docs_url": "https://docs.example.com/errors/VALIDATION_ERROR",
    "request_id": "req_abc123"
  }
}
Enter fullscreen mode Exit fullscreen mode

Every error should tell the developer: what went wrong, where, why, and how to fix it.

7. Async Operations

# Start long-running task
POST /api/reports/generate
Status: 202 Accepted

{
  "data": {
    "id": "job_abc123",
    "status": "pending",
    "created_at": "2026-05-16T01:00:00Z"
  },
  "_links": {
    "self": "/api/jobs/job_abc123",
    "cancel": "/api/jobs/job_abc123/cancel"
  }
}

# Check status
GET /api/jobs/job_abc123

{
  "data": {
    "id": "job_abc123",
    "status": "completed",
    "result_url": "/api/reports/rpt_456"
  }
}
Enter fullscreen mode Exit fullscreen mode

8. Webhooks (Push, Don't Pull)

// Instead of making clients poll:
// POST /api/webhook/register
// { "events": ["order.created", "payment.completed"], "url": "https://your-app.com/webhook" }

// You push events to them:
app.post('/webhook', async (req, res) => {
  const event = req.headers['x-event-type'];
  const signature = req.headers['x-signature']; // Verify!

  switch (event) {
    case 'order.created':
      await handleNewOrder(req.body);
      break;
    case 'payment.completed':
      await handlePayment(req.body);
      break;
  }

  res.json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

9. Rate Limiting Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 943
X-RateLimit-Reset: 1715854800
Retry-After: 60
Enter fullscreen mode Exit fullscreen mode

Always include these headers. Let clients handle rate limits gracefully instead of failing hard.

10. OpenAPI/Swagger Spec

# openapi.yaml — your API's contract
openapi: 3.1.0
info:
  title: My API
  version: 1.0.0
  contact:
    email: support@example.com

paths:
  /users:
    get:
      summary: List users
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
      responses:
        '200':
          description: User list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
                  pagination:
                    $ref: '#/components/schemas/Pagination'

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
          format: email
Enter fullscreen mode Exit fullscreen mode

Benefits: Auto-generate docs, client SDKs, tests, and mock servers from one spec file.

The Complete Node.js Example

const express = require('express');
const z = require('zod');

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

// Zod schemas as single source of truth
const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['user', 'admin']).default('user'),
});

// POST /api/v1/users
app.post('/api/v1/users', async (req, res, next) => {
  try {
    // Validate input
    const body = createUserSchema.parse(req.body);

    // Create user
    const user = await User.create(body);

    // Return consistent response
    res.status(201).json({
      data: serialize(user),
      meta: { request_id: req.id },
    });
  } catch (err) {
    if (err instanceof z.ZodError) {
      return res.status(422).json({
        error: {
          code: 'VALIDATION_ERROR',
          message: 'Invalid input',
          details: err.errors.map(e => ({
            field: e.path.join('.'),
            issue: e.message,
          })),
        },
      });
    }
    next(err);
  }
});

// GET /api/v1/users with filtering/pagination
app.get('/api/v1/users', async (req, res) => {
  const { page = 1, limit = 20, sort = '-created_at', q } = req.query;

  const result = await User.findAndCountAll({
    where: buildWhereClause(q),
    order: parseSort(sort),
    limit: Math.min(limit, 100),
    offset: (page - 1) * limit,
  });

  res.json({
    data: result.rows.map(serialize),
    pagination: {
      page: parseInt(page),
      limit: parseInt(limit),
      total: result.count,
      total_pages: Math.ceil(result.count / limit),
    },
  });
});

// Global error handler
app.use((err, req, res, _next) => {
  console.error(`[ERR] ${req.id}:`, err);
  res.status(err.statusCode || 500).json({
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: process.env.NODE_ENV === 'production' 
        ? 'Internal server error' 
        : err.message,
      ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
    },
    meta: { request_id: req.id },
  });
});
Enter fullscreen mode Exit fullscreen mode

The API Checklist

  • [ ] Consistent response envelope ({ data, meta })
  • [ ] Cursor-based pagination for large datasets
  • [ ] Correct HTTP status codes
  • [ ] Request validation with clear error messages
  • [ ] Rate limiting with informative headers
  • [ ] API versioning in URL
  • [ ] OpenAPI spec (up-to-date!)
  • [ ] Webhook support for async events
  • [ ] Request IDs for debugging
  • [ ] CORS configured properly
  • [ ] Authentication docs (Bearer token, OAuth2)
  • [ ] SDK/client library available
  • [ ] Sandbox/test environment
  • [ ] Changelog / migration guide between versions

What's the best/worst API you've used? Share in the comments.

Follow @armorbreak for more backend development guides.

Top comments (0)