Over the past year of building production backends at university, I have made every classic REST API mistake: inconsistent error formats, missing pagination, no versioning, unprotected endpoints. In this article I consolidate the patterns I now apply to every Express.js project — from PerSplit's WebSocket API to WanderLust's listing endpoints.
1. URL Structure and Versioning
Every API I build is versioned from day one. Even if you never release v2, versioning signals to consumers that you are thinking about long-term compatibility.
// Good — versioned, resource-oriented, noun-based
GET /api/v1/groups
POST /api/v1/groups
GET /api/v1/groups/:groupId
GET /api/v1/groups/:groupId/expenses
POST /api/v1/groups/:groupId/expenses
// Avoid — verb-based, unversioned
GET /getGroups
POST /createExpense/:groupId
In Express, I handle versioning cleanly with a router prefix:
const v1Router = express.Router();
v1Router.use('/groups', groupRoutes);
v1Router.use('/users', userRoutes);
app.use('/api/v1', v1Router);
2. Consistent Error Response Format
Nothing breaks client code faster than inconsistent error shapes. I define a single error envelope and throw it everywhere:
// utils/AppError.js
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code; // machine-readable: 'RESOURCE_NOT_FOUND'
this.isOperational = true;
}
}
// Global error handler middleware
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({
status: 'error',
code: err.code || 'INTERNAL_ERROR',
message: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});
Every error response now has the same shape: status, code, and message. Flutter and web clients can pattern-match on code to show localised error UI.
3. Pagination — Cursor-Based Over Offset
Offset pagination (?page=3&limit=20) seems simple but breaks under concurrent inserts — if someone adds a record between page 1 and page 2 fetches, you get duplicate items. For feeds and listings I always use cursor-based pagination:
GET /api/v1/groups/:id/expenses?cursor=<lastId>&limit=20
// In the service
const expenses = await Expense.find({
groupId,
_id: { $lt: new mongoose.Types.ObjectId(cursor) }
})
.sort({ _id: -1 })
.limit(limit + 1); // fetch one extra to know if there's a next page
const hasMore = expenses.length > limit;
return {
data: expenses.slice(0, limit),
nextCursor: hasMore ? expenses[limit - 1]._id : null
};
4. Input Validation at the Route Layer
I use express-validator as route-level middleware so invalid data never reaches the service layer:
const { body, validationResult } = require('express-validator');
const validateExpense = [
body('amount').isFloat({ min: 0.01 }).withMessage('Amount must be positive'),
body('description').trim().notEmpty().isLength({ max: 200 }),
body('splitType').isIn(['equal', 'percentage', 'exact']),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(new AppError('Validation failed', 422, 'VALIDATION_ERROR'));
}
next();
}
];
5. Rate Limiting
Without rate limiting, any unauthenticated endpoint is a DDoS vector. I add express-rate-limit globally with stricter limits on auth routes:
const rateLimit = require('express-rate-limit');
const globalLimiter = rateLimit({ windowMs: 15*60*1000, max: 200 });
const authLimiter = rateLimit({ windowMs: 15*60*1000, max: 10 });
app.use('/api', globalLimiter);
app.use('/api/v1/auth', authLimiter);
6. Controller–Service–Repository Separation
Mixing database queries, business logic, and HTTP concerns in one file is the fastest way to create untestable code. I split every domain into three layers:
- Controller — parse request, call service, send response. No DB access.
- Service — business logic, orchestration, error throwing. No req/res objects.
- Repository — all MongoDB queries. Returns plain objects.
// controllers/expenseController.js
exports.createExpense = async (req, res, next) => {
try {
const expense = await expenseService.create(req.user.id, req.body);
res.status(201).json({ status: 'success', data: expense });
} catch (err) { next(err); }
};
7. HTTP Status Codes — Use Them Correctly
One pet peeve: returning 200 OK with { success: false } in the body. HTTP status codes exist for a reason. My cheat sheet:
200— successful GET or generic success201— resource created (POST)204— deleted, no body400— bad request / validation error401— unauthenticated403— authenticated but not authorised404— resource not found409— conflict (duplicate)422— unprocessable entity429— rate limit exceeded500— unhandled server error
Dig deeper: Implementing JWT Authentication in Production Node.js Systems — securing your APIs with proper access token flows.
Conclusion
Scalable REST API design is not about clever code — it is about consistency, predictability, and constraint. Define your conventions once, document them in a README or Swagger spec, and enforce them with middleware. Your future self (and your teammates) will thank you.
I apply all these patterns in my projects. If you are a recruiter looking for a backend developer who cares about API quality, let's talk.