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:

// 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:

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.