Book a call
  1. Blog
  2. Open Source
  3. What I wish someone told me before I…

What I wish someone told me before I wrote my tenth Express API

The setup work every Express API repeats: folders by resource, validation at the edge, one error format, access rules on every route, and a starter checklist so your tenth API costs less than your first.

A stack of three cards on a soft lilac background, the top card showing one resource file with an owner or admin access chip

I have started somewhere around ten Express APIs from an empty folder. Maybe more, I stopped counting the ones that never shipped. And every single time, the first two weeks looked the same. Make the folders. Wire up validation. Write an error handler. Add auth. Copy the CRUD controller from the last project and rename things. Forget one access check. Find it later, usually at a bad time.

None of that work is the product. It is the floor you stand on before you can start building the product. So here is what I wish someone had told me before project number one, roughly in the order it bites.

Organize by resource, not by type

Most Express tutorials hand you controllers/, models/, routes/ and services/. It looks tidy with two models. With twenty, every change to orders means opening four folders and scrolling past nineteen unrelated files to find the one you need.

Group by resource instead. Everything about orders lives in one folder: the schema, the rules, the business logic, the custom routes. Deleting a feature means deleting a folder. A new person on the team can read one directory and understand one thing completely.

app/
  resources/
    order/
    product/
    user/

Shared plumbing like auth, errors and config lives one level up, away from the resources. Keep those two worlds separate and the structure survives growth far better than the type-based layout ever did for me.

Validate at the edge, and say no to fields you did not ask for

Validation belongs at the very front of the request, before any of your code touches the body. Not inside the service, not “the model will catch it”. At the edge.

The part people skip is unknown fields. If your update route does Model.findByIdAndUpdate(id, req.body), a client can send role: "admin" or someone else’s user id and you just saved it. That is mass assignment, one of the oldest bugs on the web. The fix is boring: an allowlist of what each route accepts, and a 422 for anything else. Not silently stripped. Rejected, so the client finds out.

const allowed = ['title', 'body', 'tags'];
const unknown = Object.keys(req.body).filter((k) => !allowed.includes(k));
if (unknown.length) {
  return res.status(422).json({
    error: { code: 'VALIDATION_ERROR', details: unknown },
  });
}

A few lines, and a whole class of bugs is gone. Zod, Joi or express-validator do the same thing with more polish. Pick one and use it on every route, not just the ones that feel risky.

One error format, and catch the async ones

Put three of your error responses side by side. If one is { message }, one is { error } and one is an HTML stack trace, your frontend has been writing three parsers. Decide on one shape, include a request id so you can find the log line, and send every error through one handler.

The other classic: Express 4 does not pass a rejected promise from an async handler to your error middleware. The request hangs or you get an unhandled rejection in the logs. That is why so many codebases wrap every route in an asyncHandler helper. Express 5 handles rejected promises for you, which is reason enough to start new projects on it.

Every route needs a written access rule

This is the one that hurts. Most APIs I review do authentication fine: you need a token to get in. Where they fall apart is authorization. Who may list orders? Read one? Update it? Delete it? Very often the honest answer is “anyone with a token”, because nobody ever wrote it down.

My rule now is that every route declares its policy right next to it, even the public ones. Public should be a decision, not a default. And for anything users own there are two checks, not one: may this user update orders at all, and is this particular order theirs. The second one is the one that gets forgotten. It is also why a list endpoint should return only the caller’s own records instead of everything.

One habit on testing, since I will go deeper on it in a later post: test each route as every role, including anonymous and “signed in, but not the owner”. A happy path test with an admin token tells you almost nothing about who else can get in.

Allowlist your filters and sorts

Passing req.query straight into find() is how a query string like ?owner[$ne]=x turns into a database query you never meant to write. Decide which fields can be filtered and sorted, cap the page size, and answer with a 400 for everything else. There is a whole post coming on this one, so I will leave it there.

What I ended up building

After enough of these I did the lazy engineer thing and stopped writing the floor by hand. That turned into CalmAPI. The first version used a six-file module pattern per resource: controller, service, model, route, DTO and settings. It worked, but it was still six files for every resource, and they drifted apart.

CalmAPI 2, which is in beta right now, puts the fields and the access policy for a resource in one file. This is the post resource from a project I generated this morning with npm create calmapi@latest demo -- --yes --template api:

import { resource, f } from '#calm';

export default resource('post', {
  fields: {
    title: f.string().required().max(200).searchable().sortable(),
    slug: f.string().readOnly().unique().filterable(),
    body: f.text().required().max(50_000),
    status: f.enum(['draft', 'published']).default('draft').filterable(),
    tags: f.array(f.string().max(30)).max(10).default([]).filterable(),
    author: f.ref('user').owner(), // set from the session; powers the 'owner' policy
    publishedAt: f.date().readOnly().sortable(),
  },
  policy: {
    list: 'public', // drafts are filtered out in post.service.js scope()
    read: 'public',
    create: 'authenticated',
    update: 'owner | admin',
    delete: 'owner | admin',
  },
});

From that one file you get five routes, validation that rejects unknown and read-only fields with a 422, filtering and sorting only on the fields you mark, and an OpenAPI document. Business logic goes in an optional service file, custom endpoints in an optional routes file. The framework itself is copied into your project under calm/, so you can read every line of it, and calm upgrade merges framework updates while keeping your edits. npx calm routes prints every route with its policy, and npx calm audit calls every route as every role on a throwaway database.

It is beta, and I mean that. The 2.0 line is on the npm next tag as I write this, so expect rough edges. If you try it and something breaks, I genuinely want to hear about it.

A starter checklist

  • Folders by resource, shared plumbing kept separate
  • Validation on every route, unknown fields rejected with a 422
  • One error shape with a request id, async errors handled (or Express 5)
  • A written policy on every route, including the public ones
  • Owner checks on update and delete, and lists scoped to the caller
  • Filter and sort allowlists, page size capped
  • Password hashes and internal flags never serialized
  • Tests that call each route as anonymous, signed in, owner and admin
  • One command or script that prints every route with its access rule

Use whatever you like to get there. Hand-rolled Express, NestJS, Fastify with its schema validation, CalmAPI, or something you build yourself. The habit matters more than the tool. Your tenth API should not cost what your first one did.

If your API sits on MongoDB, I also wrote about why queries that are fast in dev can crawl in production. Our open source work lives at broadifitech.com/open-source, and if you want a second pair of eyes on an API before it goes live, write to contact@broadifitech.com.

Sunil Kr. Samanta

Co-Founder & CTO at Broadifi Tech. Weaving together technology and inner exploration. Built and scaled startups while diving deep into consciousness studies. Equally at home discussing system design or Swami Vivekananda. Currently focused on creating tech that elevates humanity.

Everything by Sunil Kr. Samanta

Start the thread

Your email address will not be published. Required fields are marked *