Building a REST API That Developers Actually Love Using (2026)
I've consumed hundreds of APIs. Here's what separates the great ones from the frustrating ones.
The Problem With Most APIs
{
"error": "Error occurred",
"code": 500,
"message": null,
"data": [],
"success": false
}
We've all seen this. What went wrong? Where? How do I fix it?
A great API is self-documenting, predictable, and helps developers succeed.
1. Consistent Response Structure
Pick ONE structure and stick to it everywhere:
// ✅ Success response
{
"data": { "id": 1, "name": "Alice", "email": "alice@example.com" },
"meta": { "page": 1, "per_page": 20, "total": 150 }
}
// ✅ Error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email address is invalid",
"details": [
{ "field": "email", "issue": "Must be a valid email format" }
]
}
}
Rules
| Rule | Why |
|---|---|
Always wrap data in data key |
Easy to extract, predictable |
Errors go in error object |
One place to check for problems |
Include error code for programmatic handling |
Clients can switch on error type |
Add details array for validation errors |
Show exactly which fields failed |
| Never expose stack traces in production | Security risk + useless to consumers |
2. Proper HTTP Status Codes
// Use them correctly — clients depend on these codes
// Success codes
200 OK // Standard success
201 Created // POST that creates a resource
204 No Content // DELETE or PUT that returns nothing
// Client errors (your API's job to communicate clearly)
400 Bad Request // Malformed syntax / missing fields
401 Unauthorized // No auth token / expired token
403 Forbidden // Authenticated but not allowed
404 Not Found // Resource doesn't exist
409 Conflict // Unique constraint violated (e.g., duplicate email)
422 Unprocessable // Valid JSON but business rule violated
429 Too Many Requests // Rate limited — include Retry-After header
// Server errors
500 Internal Server Error // Something unexpected happened
502 Bad Gateway // Upstream service down
503 Service Unavailable // Maintenance mode
Implementation Example (Express.js)
// middleware/response.js
class ApiResponse {
static success(res, data, meta = {}, status = 200) {
return res.status(status).json({ data, meta });
}
static created(res, data) {
return res.status(201).json({ data });
}
static noContent(res) {
return res.status(204).send();
}
static error(res, code, message, details = [], status = 400) {
return res.status(status).json({
error: { code, message, details }
});
}
static notFound(res, resource = 'Resource') {
return this.error(
res, 'NOT_FOUND', `${resource} not found`, [], 404
);
}
static unauthorized(res) {
return this.error(
res, 'UNAUTHORIZED', 'Authentication required', [], 401
);
}
static forbidden(res) {
return this.error(
res, 'FORBIDDEN', 'You do not have permission', [], 403
);
}
}
module.exports = ApiResponse;
// routes/users.js
const router = require('express').Router();
const ApiResponse = require('../middleware/response');
// GET /api/users/:id
router.get('/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return ApiResponse.notFound(res, 'User');
}
return ApiResponse.success(res, user);
});
// POST /api/users
router.post('/', async (req, res) => {
const { email, name } = req.body;
if (!email || !name) {
return ApiResponse.error(res,
'VALIDATION_ERROR',
'Missing required fields',
[
...(email ? [] : [{ field: 'email', issue: 'Required' }]),
...(name ? [] : [{ field: 'name', issue: 'Required' }])
]
);
}
try {
const user = await User.create({ email, name });
return ApiResponse.created(res, user);
} catch (err) {
if (err.code === '23505') { // PostgreSQL unique violation
return ApiResponse.error(res, 'DUPLICATE',
'An account with this email already exists',
[{ field: 'email', issue: 'Already registered' }],
409
);
}
throw err; // Let global handler deal with unknown errors
}
});
3. Pagination Done Right
// GET /api/items?page=2&per_page=20&sort=-created_at
router.get('/', async (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const perPage = Math.min(100, Math.max(1, parseInt(req.query.per_page) || 20));
const sort = req.query.sort || '-created_at';
const { items, total } = await Item.findAndCountAll({
limit: perPage,
offset: (page - 1) * perPage,
order: sort.replace('-', '')
});
return ApiResponse.success(res, items, {
page,
per_page: perPage,
total,
total_pages: Math.ceil(total / per_page),
has_next: page * perPage < total,
has_prev: page > 1
});
});
Key points:
- Default
per_page= 20 (not 10, not unlimited) - Cap at 100 (prevent abuse)
- Return pagination metadata in every list response
- Support cursor-based pagination for large datasets
4. Rate Limiting
// Simple in-memory rate limiter
const rateLimitMap = new Map();
function rateLimit(maxRequests = 100, windowMs = 60000) {
return (req, res, next) => {
const key = req.ip || req.headers['x-forwarded-for'];
const now = Date.now();
if (!rateLimitMap.has(key)) {
rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
return next();
}
const record = rateLimitMap.get(key);
if (now > record.resetAt) {
record.count = 1;
record.resetAt = now + windowMs;
return next();
}
if (record.count >= maxRequests) {
const retryAfter = Math.ceil((record.resetAt - now) / 1000);
res.set('Retry-After', retryAfter);
res.set('X-RateLimit-Limit', maxRequests);
res.set('X-RateLimit-Remaining', 0);
res.set('X-RateLimit-Reset', new Date(record.resetAt).toISOString());
return ApiResponse.error(res, 'RATE_LIMITED',
'Too many requests. Please slow down.', [], 429);
}
record.count++;
res.set('X-RateLimit-Limit', maxRequests);
res.set('X-RateLimit-Remaining', maxRequests - record.count);
next();
};
}
// Apply to all routes
app.use(rateLimit(100, 60_000)); // 100 requests/min
// Stricter for sensitive endpoints
app.post('/auth/login', rateLimit(5, 60_000)); // 5 attempts/min
For production, use a proper store (Redis):
npm install express-rate-limit redis
const RateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const limiter = RateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rl:'
}),
windowMs: 60_000,
max: 100,
standardHeaders: true, // Send RateLimit-* headers
legacyHeaders: false
});
5. Versioning Your API
/api/v1/users ← Current version
/api/v2/users ← Future version (breaking changes)
/api/v3/users ← Even further future
// app.js
const v1Router = require('./routes/v1');
app.use('/api/v1', v1Router);
// When you need breaking changes:
const v2Router = require('./routes/v2');
app.use('/api/v2', v2Router);
// Deprecation notice on old version
app.use('/api/v1', (req, res, next) => {
res.set('Warning', '299 - "Deprecated. Migrate to /api/v2 by 2026-12-01"');
next();
});
6. Input Validation
Never trust client input:
const { body, param, query, validationResult } = require('express-validator');
// Validation rules
const createUserRules = [
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Must be a valid email'),
body('name')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('Name must be 1-100 characters')
.escape(),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Password must contain uppercase, lowercase, and number')
];
// Handler
router.post('/',
createUserRules,
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return ApiResponse.error(res, 'VALIDATION_ERROR',
'Input validation failed',
errors.array().map(e => ({
field: e.path,
issue: e.msg
}))
);
}
// ... create user
}
);
7. Authentication Pattern
// JWT-based auth (simple & stateless)
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return ApiResponse.unauthorized(res);
}
const token = header.substring(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload; // { id, email, role }
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return ApiResponse.error(res, 'TOKEN_EXPIRED',
'Token has expired. Please re-authenticate.', [], 401);
}
return ApiResponse.unauthorized(res);
}
}
// Role-based access
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return ApiResponse.forbidden(res);
}
next();
};
}
// Usage
router.get('/admin/users', authMiddleware, requireRole('admin'), adminHandler);
8. Documentation (OpenAPI/Swagger)
// swagger.js
const swaggerJsDoc = require('swagger-jsdoc');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'My API',
version: '1.0.0',
description: 'A well-designed REST API',
contact: { email: 'contact@agentvote.cc' }
},
servers: [{ url: 'https://api.example.com/v1' }],
components: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer' }
},
schemas: {
User: {
type: 'object',
properties: {
id: { type: 'integer' },
email: { type: 'string', format: 'email' },
name: { type: 'string' },
created_at: { type: 'string', format: 'date-time' }
}
},
Error: {
type: 'object',
properties: {
code: { type: 'string' },
message: { type: 'string' },
details: { type: 'array', items: { type: 'object' } }
}
}
}
}
},
apis: ['./routes/*.js']
};
const specs = swaggerJsDoc(options);
// In your app:
app.use('/docs', swaggerUi.serve, swaggerUi.setup(specs));
Now developers can see interactive docs at /docs.
The Complete Middleware Stack
// app.js — order matters!
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const compression = require('compression');
const app = express();
// 1. Security headers
app.use(helmet());
// 2. CORS
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true
}));
// 3. Compression
app.use(compression());
// 4. Body parsing (with size limits!)
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// 5. Request logging
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('combined'));
}
// 6. Rate limiting
app.use(rateLimit(100, 60_000));
// 7. Routes
app.use('/api/v1', v1Routes);
// 8. Error handler (MUST be last)
app.use((err, req, res, _next) => {
console.error(err.stack);
if (process.env.NODE_ENV === 'production') {
return ApiResponse.error(res, 'INTERNAL_ERROR',
'An unexpected error occurred', [], 500);
}
res.status(500).json({
error: { message: err.message, stack: err.stack }
});
});
// 9. 404 handler
app.use((_req, res) => {
ApiResponse.notFound(res, 'Endpoint');
});
Quick Checklist Before Shipping
□ Consistent response format (data/error/meta)
□ Correct HTTP status codes everywhere
□ All inputs validated and sanitized
□ Rate limiting on every endpoint
□ Auth on protected routes
□ Pagination on list endpoints
□ Versioned URL paths (/api/v1/)
□ CORS configured properly
□ Request size limits set
□ Error responses don't leak internals
□ Documentation (at least endpoint list)
□ Health check endpoint (/health)
□ Structured logging enabled
What API design patterns do you swear by?
Follow @armorbreak for more backend development guides.
Top comments (0)