Core concepts
The four ideas every other page assumes: routers, allowlists, the request lifecycle and the response envelope.
The three pieces#
Everything in the package is one of these. Knowing which one you're holding tells you where its options go.
| Piece | What it is | Use it when |
|---|---|---|
createRouter(options) | An Express router with six routes already mounted, backed by a controller. | Almost always. It's the default way to expose a model. |
new ControllerSets(options) | The six handlers — getAll, query, get, create, update, delete — without a router. | You need a different guard per verb, different paths, or only some of the routes. See Custom routes. |
createAuthRouter(options) | A separate router for sign-up, login and user management on your user model, plus the requireAuth / requireRole guards. | You want built-in authentication. See Authentication. |
createRouter accepts every ControllerSets option plus three router-only ones: middlewares, enableQuery and upload. The createRouter page lists them all.
Nothing is open until you open it#
One idea explains almost every option. A generated API that exposes everything by default is convenient on day one and a breach on day ninety: someone filters on passwordResetToken, or POSTs {"role":"admin"}. So here, a field is not filterable, not sortable and not searchable until you name it. Reads are capped. Uploads are private. Each option is an allowlist that opens one thing.
| A client wants to… | Option that allows it | If you say nothing |
|---|---|---|
| Filter by exact value | query | Nothing is filterable |
Range, compare, or filter in a QUERY body | filterableFields | Same fields as query |
| Sort | sortableFields | query fields + the orderBy field |
| Search text | search | Search does nothing |
| Write a field | allowedFields / blockedFields | Every schema field — with a startup warning |
| Read the whole collection | maxLimit | Capped at 100 documents |
| Send a regular expression | allowRawRegex | Escaped, matched literally |
| Upload a file type | upload.allowedMimeTypes | JPEG, PNG, GIF, WebP, AVIF, PDF |
| Get a public file URL | upload.acl | private |
| Call the route at all | middlewares | Anyone — routes are public |
Two defaults are open, and both are yours to close. Writable fields: requiring allowedFields would break every existing app on upgrade, so the library warns at startup instead. Who may call a route: the library can't know your users. If you set only two things on every router, make them allowedFields and middlewares.
Fields no configuration can unlock#
Whatever you allow, these are stripped from every request body. They are database bookkeeping or MongoDB syntax, never client data:
_id
__v
createdAt
updatedAt
any $-prefixed key
any key containing .
__proto__
constructor
prototype
To MongoDB, $ starts an operator and . reaches into a subdocument, so {"$where": "…"} or {"owner.role": "admin"} is an instruction, not data. __proto__ survives JSON.parse as a real key and can re-point an object's prototype. The check runs on nested values too, up to eight levels deep; anything deeper is refused.
A request, step by step#
What happens between GET /api/products?s=laptop and the JSON that comes back:
Middlewares
Auth, rate limits, tenancy
Build filters
Only allowlisted fields
Apply search
Escaped, relations resolved
onGet
Choose populate & select
Query
Capped at maxLimit
JSON out
Always the same envelope
A write (POST, PATCH) takes a different middle path:
- Your
middlewaresrun. - On an upload router, files are checked, stored in S3, and replaced in
req.bodyby their URLs. - The body is filtered to
allowedFields, minusblockedFieldsand the fields above. If nothing is left: 400. - Your
validatehook runs on what's left, and may reject or rewrite it. - Mongoose validates against the schema and saves.
- On create,
runAfterCreateruns. With a cache, the model's cached reads are cleared.
A QUERY read is identical to GET / except for step two: filters come from a JSON body instead of the query string, checked against the same allowlists.
One response envelope#
Every endpoint answers in the same shape, so a client needs one code path:
// Success: data is an object (one record) or an array (a list)
{ "success": true, "data": … , "pagination": { … } } // pagination only on paginated lists
// Delete
{ "success": true, "message": "Item successfully deleted." }
// Failure
{ "success": false, "error": "Human-readable message.", "fields": { … }, "requestId": "…" }
Every shape, and when each optional key appears: HTTP API → Responses.
How errors flow#
There are two kinds of failure, and they take different paths:
Answered directly
A malformed id, a field that isn't sortable, a failed validation. The handler responds with the right 4xx and a message written to be read — even if you forgot errorHandler.
Thrown to errorHandler
A database outage, a bug in your hook. Express 5 forwards it to errorHandler, which logs the detail with a requestId and sends the client only "Internal Server Error".
In your own hooks and middleware, throw HttpError(status, message) or ValidationError(message, fields) to produce a deliberate 4xx. Anything else you throw becomes a 500.
Words used in these docs#
| Word | Meaning here |
|---|---|
| Model | A Mongoose model, like Product. The only required option. |
| Router | An Express router you mount with app.use('/api/products', router). |
| Controller / handler | The function that answers a request. ControllerSets holds six of them. |
| Middleware / guard | A function that runs before the handler. A guard is middleware that decides whether the request may continue. |
| Allowlist | A list of what's permitted. Anything not on it is refused or ignored. |
| Mass assignment | A client sending {"role":"admin"} and the database saving it. allowedFields prevents it. |
| Server-owned field | A field whose value comes from the server, never the client — ownerId, author, tenantId. |
| QUERY | An HTTP method (RFC 10008) that reads like GET but carries its parameters in a JSON body. |
| Offset / cursor pagination | Page numbers (?page=4) versus "the page after this record" (?cursor=…). |