← All writing
Backend

REST API Design for Small Teams: Boring Is a Feature

You do not need GraphQL, gRPC, or a gateway. You need consistent URLs, honest status codes, and error messages a frontend developer can act on at 11pm.

Most client projects are built and maintained by one to three developers. At that scale, an API's most valuable property is not elegance — it is predictability. If a developer can guess the endpoint, the shape of the response, and what a failure looks like, the API is doing its job.

This post expands the conventions into working Express code: a router you can copy, the status-code contract with handlers attached, and the single error envelope that makes 11pm debugging survivable.

URLs that can be guessed

GET    /api/posts           # list (filterable via query string)
POST   /api/posts           # create
GET    /api/posts/:idOrSlug # read one
PUT    /api/posts/:idOrSlug # update
DELETE /api/posts/:idOrSlug # delete

Nouns, plural, nested only when ownership is real. The moment you ship /api/getAllPostsNew2, every future endpoint becomes a memory test.

The router, in full

// routes/posts.js
import { Router } from 'express';
import * as posts from '../controllers/posts.js';
import { requireAuth } from '../middleware/auth.js';

const router = Router();

router.get('/', posts.list);
router.post('/', requireAuth, posts.create);
router.get('/:idOrSlug', posts.getOne);
router.put('/:idOrSlug', requireAuth, posts.update);
router.delete('/:idOrSlug', requireAuth, posts.remove);

export default router;

// app.js
app.use('/api/posts', postsRouter);
app.use('/api/products', productsRouter);
// Every resource mounts the same way. New developer, zero surprises.

Filtering, sorting, pagination: query string, not new endpoints

The temptation is to mint /api/posts/published, then /api/posts/by-category, then /api/posts/recent — three endpoints that are really one list with three filters. Keep one endpoint and let the query string do the work:

// GET /api/posts?status=published&category=Backend&page=2&limit=10&sort=-createdAt

export async function list(req, res) {
  const page  = Math.max(1, parseInt(req.query.page) || 1);
  const limit = Math.min(50, parseInt(req.query.limit) || 20); // cap it — always

  const filter = {};
  if (req.query.status)   filter.status = req.query.status;
  if (req.query.category) filter.category = req.query.category;

  const [items, total] = await Promise.all([
    Post.find(filter)
      .sort(req.query.sort || '-createdAt')
      .skip((page - 1) * limit)
      .limit(limit)
      .lean(),
    Post.countDocuments(filter),
  ]);

  res.json({
    items,
    page,
    totalPages: Math.ceil(total / limit),
    total,
  });
}

Two boring decisions hiding in there: the limit is capped at 50 so no client can ask the database for everything at once, and the list response is always an envelope{ items, page, totalPages, total } — never a bare array. The day you need to add pagination metadata to a bare array, every consumer breaks. Start with the envelope and you never have that conversation.

Nest only when ownership is real

GET /api/jobs/:jobId/applications   # ✅ applications belong to a job
GET /api/users/:userId/orders       # ✅ orders belong to a user

GET /api/categories/:id/posts/:postId/comments/:commentId  # ❌ nobody can guess this
GET /api/comments/:id                                       # ✅ it has its own ID; address it directly

One level of nesting expresses ownership. Two levels is a scavenger hunt. If a resource has its own ID, it can have its own top-level URL.

Status codes are part of the contract

The frontend branches on the status code before it ever reads the body. Lie with the code and every consumer inherits the lie.

  • 400 — the request is malformed; say which field and why.
  • 401 — no valid session. 403 — valid session, insufficient rights. These are different bugs: one sends the user to the login page, the other tells them to ask an admin.
  • 404 — the thing does not exist. Do not return 200 with an empty body; the frontend will render a ghost.
  • 409 — conflict, like a duplicate slug. The message should name the colliding value.

What each one looks like in a handler

export async function getOne(req, res) {
  const { idOrSlug } = req.params;

  const query = mongoose.isValidObjectId(idOrSlug)
    ? { _id: idOrSlug }
    : { slug: idOrSlug };

  const post = await Post.findOne(query).lean();

  if (!post) {
    // ❌ res.json(null)  — the ghost
    // ✅ an honest 404:
    return res.status(404).json({ message: 'Post not found' });
  }

  res.json(post);
}
// middleware/auth.js — where 401 and 403 part ways
export function requireAuth(req, res, next) {
  if (!req.session?.userId) {
    return res.status(401).json({ message: 'Authentication required' });
  }
  next();
}

export function requireAdmin(req, res, next) {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ message: 'Admin access required' });
  }
  next();
}
// create — 400 for bad input, 409 for collisions, 201 (not 200) on success
export async function create(req, res) {
  try {
    const post = await Post.create(req.body);
    res.status(201).json(post);
  } catch (err) {
    if (err.name === 'ValidationError') {
      const field = Object.keys(err.errors)[0];
      return res.status(400).json({
        message: err.errors[field].message,   // "Title cannot exceed 160 characters"
        field,                                 // "title" — the form knows which input to mark
      });
    }
    if (err.code === 11000) {
      const field = Object.keys(err.keyValue)[0];
      return res.status(409).json({
        message: `A post with this ${field} already exists: "${err.keyValue[field]}"`,
        field,
      });
    }
    throw err; // anything else is a genuine 500 — let the error middleware own it
  }
}

Notice the division of labor: the handler translates expected failures (validation, duplicates) into honest codes, and rethrows everything else. Unexpected failures belong to exactly one place — which brings us to the envelope.

One error shape, everywhere

{ "message": "A post with this slug already exists: \"my-first-post\"", "field": "slug" }

Every error, same envelope: a human-readable message, always; a field when the error maps to an input. The frontend writes its error handling once and trusts it forever:

// The only error handler the frontend ever needs
async function api(path, options) {
  const res = await fetch(`/api${path}`, options);
  const body = await res.json().catch(() => ({}));

  if (!res.ok) {
    throw new ApiError(body.message || 'Something went wrong', res.status, body.field);
  }
  return body;
}

To make the envelope impossible to forget on the server, enforce it in one Express error middleware instead of in every handler:

// middleware/error.js — mounted LAST, after all routes
export function errorHandler(err, req, res, next) {
  // Expected, already-shaped errors pass through
  if (err.statusCode) {
    return res.status(err.statusCode).json({ message: err.message, field: err.field });
  }

  // Everything else: log the details, hide the stack trace
  console.error(err);
  res.status(500).json({ message: 'Internal server error' });
}

Pair it with a tiny async wrapper so a rejected promise in any handler lands in that middleware instead of hanging the request:

// utils/asyncHandler.js
export const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// routes/posts.js
router.get('/:idOrSlug', asyncHandler(posts.getOne));

That's the whole error architecture: handlers shape the failures they expect, the middleware catches the rest, and every consumer sees one envelope. This single convention has saved me more debugging hours than any tool.

Two more boring conventions worth their weight

Return the resource you just changed

// ✅ POST returns 201 + the created document (with its _id and timestamps)
// ✅ PUT returns 200 + the updated document
const post = await Post.findOneAndUpdate(query, req.body, {
  new: true,           // return the AFTER state, not the before
  runValidators: true,
});
res.json(post);

The frontend that just created a post needs its _id to navigate to it. Returning { "success": true } forces an immediate second request to fetch what the server was already holding.

Prefix everything with /api, and version only when you must

app.use('/api/posts', postsRouter);

The prefix keeps API routes from colliding with pages and makes proxy rules one line. As for /api/v1 — for a one-to-three developer team that owns both the frontend and backend, you are both consumers; coordinate the change in one deploy and skip the versioning ceremony until an external consumer forces it. Boring means not building machinery for problems you don't have.

Boring APIs get integrated, extended, and handed off without drama. In client work, that is the whole game.

#api#rest#nodejs#express
AA
Written byAmanat Ali

A full-stack developer who builds websites end-to-end — Next.js, Node.js, and MongoDB — and writes buildlog in the margins.