One model in,a whole REST API out.
Express Controller Sets is a Node.js library that turns a Mongoose model into five working endpoints — list, read, create, update, delete — with filtering, search, pagination and optional S3 file uploads already wired up. You write the model and the rules; it writes the controller.
What you write
createRouter({
model: Product,
query: ['category'],
search: ['name'],
allowedFields: ['name', 'price'],
});
What you get
- GET
/productsfilter · search · page - POST
/productscreate - GET
/products/:idread one - PATCH
/products/:idupdate - DELETE
/products/:iddelete
Never built a REST API before? Start here.
Skip ahead if router.get('/:id', …) is muscle memory for you.
The paperwork analogy. Every office form is different, but the clerk's job is always the same: check the form is filled in correctly, file it, find it again later, amend it, shred it.
Your models are the forms. The clerk's job — read, create, update, delete — is code you would otherwise write again for every single model, and get subtly wrong in a different way each time. This library is the clerk. You hand it a model and a list of house rules, and it handles the paperwork identically everywhere.
Words you'll see on every page
| Word | What it actually means |
|---|---|
| Model | A Mongoose model — the shape of one kind of record, like Product or
User. It's the only required option. |
| Router | A bundle of URL routes you attach to your Express app with
app.use('/api/products', router). |
| Controller | The functions that actually answer a request. This library generates them for you;
the class is called ControllerSets. |
| CRUD | Create, Read, Update, Delete — the four things every API does. |
| Middleware | A function that runs before your handler, typically to check who the caller
is. Yours to supply, via the middlewares option. |
| Allowlist | A list of what is permitted. Anything not on the list is refused. This library is built on allowlists — see Nothing is open. |
| Mass assignment | The bug where a client sends {"role":"admin"} and your database
cheerfully saves it. allowedFields is what stops it. |
| ACL | Access Control List — on S3, whether an uploaded file is readable by the public.
Defaults to private here. |
Everything this package generates is a public endpoint. There is no login check, no ownership check and no rate limit unless you add one. It builds the routes; it does not decide who may call them. Read Auth & middlewares before this reaches the internet.
What you actually get
Three exports carry almost all the value. The rest is detail.
createRouter()
Five REST endpoints for one model, ready to mount.
createRouterS3upload()
The same five, plus file uploads to S3 on POST and PATCH.
errorHandler
One Express error handler that turns any failure into consistent JSON.
That is the whole surface for most projects:
import { createRouter, createRouterS3upload, errorHandler } from 'express-controller-sets';
app.use('/api/products', createRouter({ model: Product, /* rules */ }));
app.use('/api/users', createRouterS3upload({ model: User, /* rules */ }));
app.use(errorHandler); // always last
Four more exports exist for when you need them: the ControllerSets class (wire handlers into your own routes), fileUploadMiddleware
(uploads without the CRUD), compressImage, and HttpError /
escapeRegex for your own code.
Install
One command for CRUD. A second one only if you want uploads.
npm install express-controller-sets mongoose express
Uploads need two more packages. They are optional peers — the library imports them lazily, so if you never create an upload router you never pay for them:
npm install multer @aws-sdk/client-s3
# optional: image compression before upload
npm install sharp
Node 18 or newer
Tested on Node 18, 20 and 22 on every commit.
ESM only
Use import, and add "type": "module" to your package.json.
Types built in
No @types/… package to install. Autocomplete works immediately.
Nothing loads unused
sharp ships a native binary and the AWS SDK is large. Neither is imported until
an upload actually happens.
Peer dependencies, in plain terms. Express and Mongoose are yours —
the library uses the same copy your app already has, so there is never a second, mismatched
Mongoose in your node_modules.
Your first API, end to end
Four steps from an empty file to five working endpoints.
-
Define a Mongoose model as you normally would
Nothing here is special to this library.
// models/Product.js import mongoose from 'mongoose'; const productSchema = new mongoose.Schema({ name: { type: String, required: true }, price: { type: Number, required: true }, description: String, category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category' }, isFeatured: { type: Boolean, default: false }, }, { timestamps: true }); export default mongoose.model('Product', productSchema); -
Build a router and state the house rules
Every option below is an allowlist. Read the comments — they are the whole security model in miniature.
import express from 'express'; import { createRouter, errorHandler } from 'express-controller-sets'; import Product from './models/Product.js'; const app = express(); app.use(express.json()); const productRouter = createRouter({ model: Product, orderBy: '-createdAt', // default sort: newest first // What a client may FILTER on: /api/products?category=65a… query: ['category', 'isFeatured'], // What a client may SEARCH in: /api/products?s=laptop search: ['name', 'description', 'category.name'], // What a client may WRITE. Without this line, every schema field // is writable — including any you never meant to expose. allowedFields: ['name', 'price', 'description', 'category'], // What a client may use with ?compareField= / ?rangeField= and ?sort= filterableFields: ['price', 'category'], sortableFields: ['price', 'name', 'createdAt'], }); app.use('/api/products', productRouter); app.use(errorHandler); // must be after your routes app.listen(3000); -
Connect to MongoDB and start the server
import mongoose from 'mongoose'; await mongoose.connect(process.env.MONGODB_URI); -
Call it
curl localhost:3000/api/products curl localhost:3000/api/products?s=laptop&sort=-price&page=1 curl -X POST localhost:3000/api/products \ -H 'Content-Type: application/json' \ -d '{"name":"Laptop","price":1299}'
If you skipped allowedFields, look at your console. The library
prints a warning naming the model, once per process. It is not decoration — until you
configure a field policy, a client can write any field your schema defines.
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 params
Apply search
Escaped, relations resolved
onGet
Choose populate & select
Query
Capped at maxLimit
JSON out
Always the same shape
A write (POST or PATCH) follows the same path with two differences: on an
upload router the file middleware runs first and replaces the file with its URL, and the request
body is filtered down to the fields you allowed before it reaches Mongoose.
Where do errors go? Deliberate 4xx answers — a bad ID, a field that isn't
filterable — are returned by the controller itself, so they work even if you forgot to mount
errorHandler. Anything unexpected is re-thrown; Express 5 forwards it to your
error handler instead of hanging the request.
Nothing is open until you open it
One idea explains almost every option in this library. Learn it once and the rest of the page reads itself.
"It works out of the box"
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"}, and nothing stops them.
Implicit deny
A field is not filterable, not sortable and not writable until you name it. Reads are capped. Uploads are private. Every convenience is opt-in, in one line.
What is closed, and the key that opens it
| A client wants to… | Option | Default if you say nothing |
|---|---|---|
| Filter by a field | query |
Nothing is filterable |
| Compare or range a field | filterableFields |
Falls back to query |
| Sort by a field | sortableFields |
Falls back to query + your orderBy field |
| Search text | search |
Search does nothing |
| Write a field | allowedFields / blockedFields |
Everything writable — plus a startup warning |
| Read the whole collection | maxLimit |
Capped at 100 documents |
| Send a regular expression | allowRawRegex |
Escaped and matched literally |
| Upload a file type | upload.allowedMimeTypes |
JPEG, PNG, GIF, WebP, AVIF, PDF only |
| Get a public file URL | upload.acl |
private |
The one default that is not closed: writable fields. Requiring
allowedFields would break every existing installation on upgrade, so instead
the library warns loudly and leaves the door open. If you configure exactly one option from
this page, make it this one.
Fields no configuration can unlock
Regardless of what 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 .
Why $ and . matter. To MongoDB they are not
characters, they are syntax: $ starts an operator and . reaches
into a subdocument. A body of {"$where": "…"} or
{"owner.role": "admin"} is an instruction, not data. The check runs on nested
values too, up to eight levels deep — beyond that the value is refused rather than vouched
for.
Options at a glance
Both router factories take one object. Five keys are consumed by the router itself; everything else is passed straight through to the controller.
createRouterS3upload({
// ---- router-level ----
middlewares: [requireAuth], // run before every route in this router
path: 'products/', // S3 key prefix (upload routers)
fields: [{ name: 'image', maxCount: 1 }], // form fields (upload routers)
imgOptimizations: 'medium', // compression level (upload routers)
upload: { acl: 'private' }, // everything else S3 (upload routers)
// ---- controller-level ----
model: Product, // the only required option
orderBy: '-createdAt',
query: ['category'],
search: ['name', 'category.name'],
allowedFields: ['name', 'price'],
blockedFields: ['isFeatured'],
filterableFields: ['price'],
sortableFields: ['price', 'createdAt'],
maxLimit: 100,
maxSearchLength: 128,
allowRawRegex: false,
lean: false,
runAfterCreate: async (doc) => {},
strictAfterCreate: false,
onGet: async (req, res) => ({ populates: [], selects: '' }),
logger: console,
legacyMode: false,
});
Full descriptions and defaults live in Every option. The sections in between explain the ones with real consequences.
The five endpoints
Identical for every model, whether you use createRouter or
createRouterS3upload.
| Method | Path | What it does | Success |
|---|---|---|---|
| GET | / |
List records. Filter, search, sort, paginate. | 200 |
| POST | / |
Create one record from the writable part of the body. | 201 |
| GET | /:id |
Fetch a single record by its 24-character ObjectId. | 200 |
| PATCH | /:id |
Partially update a record. Returns the updated document. | 200 |
| DELETE | /:id |
Permanently delete a record. | 200 |
Mount the router wherever you like — the paths above are relative to the mount point:
app.use('/api/products', createRouter({ model: Product }));
// → GET /api/products, POST /api/products, GET /api/products/:id, …
There is no PUT. Updates are PATCH only, applied
with $set, so fields you don't send are left alone. If your client sends
PUT, Express answers 404 — change the client, or add your own route.
What "update" really does
The update is a single atomic query (findByIdAndUpdate), not read-then-write, so two
simultaneous updates cannot clobber each other in the gap. Schema validators run with
context: 'query', which is what makes custom validators that use this
behave correctly on updates. A missing record returns 404 rather than creating one.
Filtering
Three ways to narrow a list, each gated by an allowlist.
Every name in query becomes a query-string parameter of the same name.
createRouter({ model: Product, query: ['category', 'isFeatured'] });
// GET /api/products?category=65af…&isFeatured=true
// → { category: '65af…', isFeatured: 'true' }
// Repeat a parameter to match any of several values:
// GET /api/products?category=65af…&category=65b0…
// → { category: { $in: ['65af…', '65b0…'] } }
A parameter that isn't in query is ignored entirely — not an error, just no
effect. A parameter whose value arrives as an object (which some query-string parsers
allow, e.g. ?price[$ne]=0) is rejected with 400, because that
shape is how operator injection gets in.
Two parameters working together: which field, and the bounds.
# price between 10 and 100 (inclusive)
GET /api/products?rangeField=price&range=10-100
# 18 and over — omit the upper bound
GET /api/users?rangeField=age&range=18-
# 100 and under — omit the lower bound
GET /api/products?rangeField=price&range=-100
The field must appear in filterableFields (which defaults to
query), otherwise the response is 400. The value is split on
a single -, so negative bounds are not expressible — use
compareField for those.
One operator against one value.
# price > 50
GET /api/products?compareField=price&compareValue=50&compareOperator=gt
# combine freely with equality filters
GET /api/users?status=active&compareField=age&compareValue=30&compareOperator=lte
compareOperator |
Meaning |
|---|---|
gt | greater than |
gte | greater than or equal |
lt | less than |
lte | less than or equal |
ne | not equal |
eq | equal — the default when omitted |
Values that look numeric are converted to numbers; everything else stays a string. Same
allowlist as ranges: not in filterableFields means 400.
Why filterable fields are allowlisted at all. Suppose your schema has
passwordResetToken and your API never returns it. If a client could write
?compareField=passwordResetToken&compareOperator=gt&compareValue=m,
the number of results answers "does this token sort after m?" — and a few hundred
of those questions reconstruct the token character by character. The data never appears in
a response; it leaks through the result count. Allowlisting the field closes it.
Search
One parameter, ?s= (or ?search=), across as many
fields as you list — including fields on related models.
createRouter({
model: Product,
search: ['name', 'description', 'category.name'],
});
// GET /api/products?s=wireless
// matches products whose name OR description contains "wireless",
// OR whose category's name contains "wireless"
How relational search works
A field with a dot is split at the first dot. The part before it must be a field on
your schema declared with ref; the rest is the path to search on the referenced model.
The library looks up the matching documents there first, then filters your collection by their IDs
— no aggregation pipeline required.
name on the Category model
profile.name on the Author model
ref
What if no relation matches? You get zero results, not "the search term was ignored". Silently dropping an unmatched search term is how a customer's search for a product nobody sells returns your entire catalogue.
Search terms are treated as text, not patterns
Regex metacharacters in the term are escaped, so ?s=c++ searches for the literal string
c++. Terms longer than maxSearchLength (128 characters) are rejected with
400.
Why escaping is not optional. An unescaped search term is a program the
client wrote and your database runs. ?s=(a+)+$ triggers catastrophic
backtracking inside mongod — one GET request, one pegged database. If you genuinely
need clients to pass patterns, allowRawRegex: true exists — put it behind
authentication and never on a public endpoint.
The single-field shorthand
createRouter({ model: Post, search: 'title' });
// Still read from ?s= / ?search= — a string just means "one field".
Sorting & pagination
Two knobs the client turns, both bounded by you.
Sorting
createRouter({
model: Product,
orderBy: '-createdAt', // your default: newest first
sortableFields: ['price', 'name', 'createdAt'], // what clients may choose
});
// GET /api/products?sort=price → cheapest first
// GET /api/products?sort=-price → dearest first (the '-' means descending)
// GET /api/products?sort=secretKey → 400 Field 'secretKey' is not sortable.
Your own orderBy is never checked against the allowlist — it's your decision, not the
client's. Only a client-supplied ?sort= is validated. If you omit
sortableFields, it defaults to everything in query plus whatever field
orderBy names.
Pagination
GET /api/products?page=2&pageSize=20
Adding ?page= switches the response into paginated form, with a
pagination block alongside the data:
{
"success": true,
"data": [ /* … */ ],
"pagination": {
"currentPage": 2,
"pageSize": 20,
"totalPages": 7,
"totalRecords": 132
}
}
| Situation | How many documents come back |
|---|---|
No ?page |
Up to maxLimit — 100 by default. Never the whole collection. |
?page=1, no pageSize |
50 — the default page size. |
?page=1&pageSize=20 |
20. |
?page=1&pageSize=5000 |
Clamped to maxLimit. A client cannot enlarge its own page. |
?page=abc or ?page=-3 |
Treated as page 1 rather than erroring. |
Upgrading and suddenly seeing only 100 rows? That's maxLimit
doing its job — in 2.x an unpaginated GET / returned every document in the
collection, which is a memory spike waiting for a big table. Paginate, or raise the cap
deliberately with maxLimit: 500.
Shaping responses with onGet
One hook that decides, per request, which relations to expand and which fields to return.
Populating relations is expensive, and different callers need different amounts of data. A mobile
list view wants names; an admin screen wants everything. onGet lets one router serve
both.
onGet: (req, res) => ({
populates: [], // Mongoose .populate() argument — string, object, or array
selects: '', // Mongoose .select() argument — e.g. 'name price -_id'
})
createRouter({
model: Product,
onGet: () => ({
populates: 'category',
selects: 'name price category',
}),
});
The hook receives the live req, so anything your auth middleware attached is
available.
createRouter({
model: Order,
middlewares: [requireAuth],
onGet: (req) => {
if (req.user?.role === 'admin') {
return {
populates: [
{ path: 'customer', select: 'name email phone' },
{ path: 'items.product', select: 'name price' },
],
selects: '', // everything
};
}
return { populates: 'items.product', selects: '-internalNotes -margin' };
},
});
Keep list responses light and expand only on the single-record route.
const onGet = async (req) => {
const isDetail = Boolean(req.params.id);
return {
populates: isDetail ? ['category', 'reviews'] : 'category',
selects: isDetail ? '' : 'name price category',
};
};
createRouter({ model: Product, onGet }); // reuse across routers
| Route | onGet applies? |
|---|---|
GET / | ✔ |
GET /:id | ✔ |
PATCH /:id (the returned document) | ✔ |
POST / | ✘ — returns the created document as-is |
DELETE /:id | ✘ — returns a message, not a document |
If your hook throws, the request still succeeds. The error is logged and
the request falls back to no populate and no select. That keeps a broken hook from taking
down every read — but it also means a hook you rely on to hide fields could
silently stop hiding them. Use selects for convenience; use schema
toJSON transforms or a separate router for anything that must never be
returned.
A note on lean. Reads return real Mongoose documents by
default, so schema.set('toJSON', { transform }) runs and any fields you strip
there really are stripped. lean: true is faster but bypasses those transforms
— only enable it if you have checked that nothing sensitive was relying on them.
Create & update
The request body is filtered before Mongoose ever sees it. This section is the single most important one for security.
Say what a client may write
// Allowlist — name what IS writable (recommended)
createRouter({
model: User,
allowedFields: ['name', 'email', 'avatar'],
});
// Blocklist — name what is NOT writable
createRouter({
model: User,
blockedFields: ['role', 'isAdmin', 'balance'],
});
// Per verb — stricter on update than on create
createRouter({
model: User,
allowedFields: {
create: ['name', 'email', 'password'],
update: ['name', 'avatar'], // email and password need their own flow
},
});
Both options may be combined; blockedFields is applied after allowedFields,
so a field on both lists is blocked.
Mongoose's strict mode is not a substitute for this. Strict
mode drops keys your schema doesn't define — it throws away typos and faithfully
saves {"role": "admin"}, because role is a real field. The only
thing standing between a signup form and an admin account is a field policy.
What a client sends vs. what gets saved
// allowedFields: ['name', 'email']
// The client POSTs:
{
"name": "Ayesha",
"email": "ayesha@example.com",
"role": "admin", // ✘ not allowlisted → dropped
"_id": "65af…", // ✘ always immutable → dropped
"$where": "1 === 1" // ✘ Mongo operator syntax → dropped
}
// What reaches the database:
{ "name": "Ayesha", "email": "ayesha@example.com" }
Dropped fields are dropped quietly — the request still succeeds with the fields that survived. One exception: if nothing survives, you get 400 "Request body contains no writable fields." rather than an empty record being created.
Debugging a field that "won't save"? Nine times out of ten it isn't in
allowedFields. Check that list first, then check for a typo against the
schema.
The after-create hook
Run something once a record exists — send a welcome email, push a job onto a queue, warm a cache.
createRouter({
model: User,
allowedFields: ['name', 'email'],
runAfterCreate: async (user) => {
await sendWelcomeEmail(user.email);
await queue.add('index-user', { id: user._id });
},
});
The hook receives the created document and is awaited before the 201 goes out.
Failures are logged, not raised
If the email service is down, the user is still created and the client still gets 201. The record exists, so reporting failure would tell the client to retry a write that already landed.
Failures propagate
The error reaches your errorHandler and the client sees a 500 — even though
the record was created. Choose this only when a missed side effect is worse than a
confusing response.
Keep it short. The client is waiting on this hook. Anything slow or failure-prone belongs on a queue — enqueue inside the hook, do the work elsewhere.
Errors & status codes
Every failure comes back in the same shape. Mount the handler once and stop writing try/catch in route files.
import { errorHandler } from 'express-controller-sets';
app.use('/api/products', productRouter);
app.use('/api/users', userRouter);
app.use(errorHandler); // AFTER all routes — Express requires this order
The error response
{
"success": false,
"error": "Validation Error: price is required",
"requestId": "0f2a5b8c-…"
}
requestId is echoed into your server log alongside the full message and stack. When a
user reports "it said Internal Server Error", that ID takes you straight to the line. A
stack field is added only when NODE_ENV=development.
What each status code means
| Code | When you'll see it | Who should fix it |
|---|---|---|
200 |
Successful read, update or delete. | — |
201 |
Record created. | — |
400 |
Malformed ID, a field that isn't filterable or sortable, an invalid filter value, a search term over 128 characters, a body with no writable fields, a schema validation failure, a rejected file type, or a Multer limit. | The caller |
404 |
No record with that ID — on read, update or delete. | The caller |
409 |
A unique index rejected the write. The message names the conflicting fields. | The caller |
500 |
Anything unclassified. The client gets "Internal Server Error" and a
requestId; the details stay in your log. |
You |
503 |
An upload route was called but S3 environment variables are missing. | You |
Why 5xx responses are deliberately vague. Mongoose and the AWS SDK write
connection strings, internal hostnames, bucket names and index definitions into
err.message. Echoing that to the client hands an attacker a map of your
infrastructure. Deliberate 4xx messages — the ones this library authors — are returned in
full, because they were written to be read by a caller.
Duplicate keys are a 409, not a 500
{ "success": false, "error": "Duplicate value for: email.", "requestId": "…" }
A unique-constraint collision is something the caller can fix by sending a different value, so it is reported as a conflict rather than a server fault. Your front end can show "that email is already registered" without parsing message text.
How uploads work
Swap one function name and POST/PATCH start
accepting files.
import { createRouterS3upload } from 'express-controller-sets';
const userRouter = createRouterS3upload({
model: User,
allowedFields: ['name', 'email', 'avatar'], // 'avatar' must be writable!
path: 'avatars/', // key prefix inside the bucket
fields: [{ name: 'avatar', maxCount: 1 }], // the multipart field name
imgOptimizations: 'medium', // optional compression
});
app.use('/api/users', userRouter);
From the browser, send multipart/form-data — regular fields and the file together:
const form = new FormData();
form.append('name', 'Ayesha');
form.append('avatar', fileInput.files[0]);
await fetch('/api/users', { method: 'POST', body: form });
// Don't set Content-Type yourself — the browser adds the multipart boundary.
What happens to the file
-
Received into memory
Multer buffers it — no temp files on disk. Size and count limits apply here.
-
The bytes are identified
The library reads the file's own signature. The
Content-Typethe client sent is ignored: it is a claim, and the bytes are the fact. An unrecognised file is rejected with 400. -
Checked against the allowlist
Only the detected type is compared to
allowedMimeTypes. Renamingpayload.htmltophoto.pngchanges nothing. -
Optionally compressed
JPEG, PNG and WebP are re-encoded if
imgOptimizationsis set. See Image optimization. -
Uploaded with a safe key and headers
Stored as
<path>/<timestamp>-<random>.<ext>, with the extension andContent-Typederived from the final bytes, an ACL ofprivate, andContent-Disposition: attachmentfor anything not on the inline-safe list. -
The URL replaces the file in
req.bodyThe CRUD controller then saves it like any other field — which is why the field must appear in
allowedFields.
// After the middleware, before the controller:
req.body.avatar === 'https://your-endpoint/bucket/avatars/1721736000000-482913746.jpg'
// Prefer an object? Set formatToUrlObject on the field:
fields: [{ name: 'avatar', maxCount: 1, formatToUrlObject: true }]
req.body.avatar === { url: 'https://…' }
// maxCount > 1 gives you an array:
fields: [{ name: 'gallery', maxCount: 5 }]
req.body.gallery === ['https://…', 'https://…']
Why the client's Content-Type is never trusted. If an attacker
uploads an HTML file labelled image/png and you store it with that label and a
public ACL, the browser renders it — as a page on your bucket's origin, with
whatever scripts it contains. That is stored cross-site scripting, delivered by your own
upload form. Sniffing the bytes, forcing attachment on anything executable and
defaulting to a private ACL each close a different half of that door.
Which types are accepted, inline, or refused
| Type | Allowed by default | Served inline | Notes |
|---|---|---|---|
| JPEG, PNG, WebP | ✔ | ✔ | Also the only formats that can be compressed. |
| GIF, AVIF | ✔ | ✔ | Stored untouched — never transcoded. |
| ✔ | ✘ | Downloaded rather than rendered. | |
| HTML, SVG, XML, JavaScript | ✘ | ✘ | Active content. Even if you allowlist it, it is always
attachment. |
| MP4, HEIC, ZIP, MP3 | ✘ | ✘ | Recognised, but you must allowlist them explicitly. |
| Anything unrecognised | ✘ | ✘ | 400. "Unknown" is not treated as "probably fine". |
Upload options
Everything about files lives in three top-level keys plus the
upload object.
createRouterS3upload({
model: Document,
path: 'documents/', // key prefix
fields: [
{ name: 'file', maxCount: 1 },
{ name: 'pages', maxCount: 10, formatToUrlObject: true },
],
imgOptimizations: 'high',
upload: {
acl: 'public-read', // opt in deliberately
allowedMimeTypes: ['image/jpeg', 'image/png', 'application/pdf'],
maxFileSize: 5 * 1024 * 1024, // 5 MB per file
maxFiles: 5,
allowClientImageOptions: false,
},
});
| Option | Default | What it controls |
|---|---|---|
path |
'files/' |
Key prefix inside the bucket. Normalised so it cannot escape upwards with
../. |
fields |
[{ name: 'file', maxCount: 1 }] |
Which multipart fields carry files, how many each accepts, and whether the saved
value is a string or { url }. |
imgOptimizations |
off | 'low', 'medium' (or 'med') or
'high'. |
upload.acl |
'private' |
S3 object ACL. 'public-read' makes the returned URL work in a browser
— and works for everyone else too. |
upload.allowedMimeTypes |
JPEG, PNG, GIF, WebP, AVIF, PDF | Compared against the detected type, never the client's header. |
upload.maxFileSize |
10 MB | Per file, in bytes. Exceeding it is a 400 from Multer. |
upload.maxFiles |
10 | Total files per request. |
upload.allowClientImageOptions |
false |
Whether a client may pick the compression level. See the warning below. |
Private files aren't reachable from a browser. With the default ACL, the
URL in your database returns 403 to the public. That is intended — serve those objects
through a presigned URL or a CDN with origin access. Each uploaded file also carries
file.key alongside file.location, which is what a presigner
needs.
Filenames are not preserved. Every object is stored as
<timestamp>-<random> plus an extension derived from its bytes. A
user-supplied name is untrusted input in a path, and two users uploading
photo.jpg must not collide. Keep the original name in a database column if you
need to show it.
Image optimization
Shrink images on the way to S3, without picking quality numbers yourself.
Set a level and the library re-encodes the image toward a target size, binary-searching encoder quality (bounded between 25 and 95, at most seven attempts) until it lands in range. You get predictable output sizes instead of a fixed quality that ruins some photos and barely touches others.
| Level | Target result | Good for |
|---|---|---|
'low' |
About 75–80% of the original size | Photography, product shots — barely visible change. |
'medium' / 'med' |
About 60–65% | The usual choice for user avatars and content images. |
'high' |
Under 1 MB: about 40–45%. Over 1 MB: a size target that scales from ~400–450 KB at 1 MB to ~600–700 KB at 5 MB. | Phone camera uploads, where originals are many megabytes. |
// On a router
createRouterS3upload({ model: Post, imgOptimizations: 'high' });
// Or directly, on a buffer you already have
import { compressImage } from 'express-controller-sets';
const smaller = await compressImage(buffer, 'image/jpeg', 'medium');
What is and isn't touched
Why GIFs are left alone. An earlier version pushed every image through the
JPEG encoder. A GIF came out as JPEG bytes stored under a .gif key with
Content-Type: image/gif — animation gone, file corrupt, and nothing in the
logs. Formats that cannot be re-encoded in place are now passed straight through, and the
stored extension is re-derived from whatever bytes actually get uploaded.
allowClientImageOptions is off for a reason. The level drives
up to seven sequential re-encodes of a multi-megabyte buffer. Let clients choose it and any
upload endpoint becomes CPU amplification: a handful of requests asking for
high can saturate your server. If you turn it on, the accepted values are
still allowlisted — read from ?imgOptimizations=, the body, or the
x-img-optimizations header.
S3 environment
Five variables. Only needed if you use an upload router.
S3_ENDPOINT=https://nyc3.digitaloceanspaces.com # required
S3_SPACES_KEY=your-access-key # required
S3_SPACES_SECRET=your-secret-key # required
S3_BUCKET_NAME=your-bucket # required
S3_REGION=us-east-1 # optional, defaults to us-east-1
The variable names carry "SPACES" because the client is configured with path-style addressing, which works with DigitalOcean Spaces, MinIO, Cloudflare R2 and Amazon S3 alike. Any S3-compatible endpoint will do.
This library does not call dotenv.config(). Loading
environment is your application's decision, not a side effect a dependency imposes. Load it
yourself before you start handling requests:
import 'dotenv/config'; // first import in your entry file
Configuration is read per request, not at import. An app that loads its environment after importing the package still works — no permanent 503 caused by a snapshot taken a moment too early. Missing variables produce a 503 and a server log naming exactly which ones are absent.
Auth & middlewares
The library builds the routes. Deciding who may call them is entirely yours.
createRouter({
model: Order,
middlewares: [requireAuth, requireRole('staff')], // run before every route
allowedFields: ['status', 'note'],
});
Everything in middlewares is applied with router.use(), so it guards
all five endpoints in that router. When different verbs need different rules, use two
routers on the same path:
// Public: anyone may read the catalogue
app.use('/api/products', createRouter({
model: Product,
query: ['category'],
allowedFields: [], // no writes get through here
}));
// Private: only staff may change it
app.use('/api/admin/products', createRouter({
model: Product,
middlewares: [requireAuth, requireRole('staff')],
query: ['category'],
allowedFields: ['name', 'price', 'category', 'isFeatured'],
}));
Per-user data ("show me only my orders") needs your own middleware.
The library has no concept of ownership. Constrain the query yourself before it reaches the
controller, and remember that a client can still ask for any ID on /:id unless
you check there too.
// A tenancy guard: force every request to be scoped to the caller
const onlyMine = (req, res, next) => {
req.query.owner = String(req.user._id); // 'owner' must be in `query`
next();
};
createRouter({
model: Order,
middlewares: [requireAuth, onlyMine],
query: ['owner', 'status'],
});
That guard covers the list endpoint. GET /:id, PATCH /:id and
DELETE /:id address a record directly, so add a middleware that loads the
record and compares its owner — or keep those verbs on an admin-only router.
Before you go live
Nine checks. None of them takes more than a minute.
Set allowedFields
On every router. If the startup console is clean, you've done it.
Add authentication
Nothing is protected until you supply middlewares.
Keep the allowlists tight
Only fields you would happily publish belong in filterableFields and
sortableFields.
Check maxLimit
100 by default. Raise it only as far as your slowest collection tolerates.
Leave allowRawRegex off
Unless the endpoint is authenticated and the callers are trusted.
Mount errorHandler
Last, after every route. Without it, stack traces can escape.
Review upload settings
ACL, MIME allowlist and size limits — decide each one deliberately.
Never commit .env
Once a key is in git history, rotating it is the only fix.
Don't ship legacyMode
It re-opens every default this release closed. It's a bridge, not a destination.
A two-minute self-test. Against a staging copy, try each of these. Every one should fail:
# write a field you never allowlisted
curl -X POST …/api/users -H 'Content-Type: application/json' \
-d '{"name":"x","role":"admin"}' # role must not persist
# sort by something private
curl '…/api/users?sort=passwordHash' # expect 400
# read the whole collection
curl '…/api/users' | jq '.data | length' # expect ≤ maxLimit
# upload HTML disguised as an image
curl -F 'avatar=@evil.html;type=image/png' … # expect 400
Upgrading from 2.x
3.0 is a security release. Every breaking change is a default that used to be unsafe.
If you run 2.x on a public endpoint, treat this as urgent. In 2.x a client could write any schema field, filter and sort on fields your API never returned, hang the database with a crafted search term, read entire collections in one request, and upload world-readable HTML to your bucket.
The fastest path
createRouter({ model: Product, legacyMode: true });
That restores 2.x behaviour so you can deploy today — then remove it one option at a time.
legacyMode re-enables every vulnerability below and is scheduled for removal in 4.0.
The full guide is in MIGRATION.md.
| What changed | What to do |
|---|---|
| Request bodies are filtered | Add allowedFields or blockedFields to every router. |
?compareField=, ?rangeField=, ?sort= are
allowlisted |
List them in filterableFields / sortableFields, or rely
on the query fallback. |
| Search terms are escaped and length-capped | Nothing, unless clients sent real patterns — then allowRawRegex: true,
behind auth. |
| Unpaginated reads capped at 100 | Paginate, or raise maxLimit deliberately. |
| Relational search resolves the full nested path | Re-check results for depth-2 paths — they were querying the wrong field before. |
String-form search now reads ?s= |
Update clients that used ?title= for search. |
| Uploads default to private, content-sniffed | Set upload.acl and upload.allowedMimeTypes if you need
the old behaviour. Audit existing objects separately — they keep their old ACL. |
| Clients no longer choose the compression level | upload.allowClientImageOptions: true to opt back in. |
| GIF and SVG are no longer transcoded | Nothing — previously they were being corrupted. |
| 5xx responses no longer echo internals; duplicates are 409 | Update any client that parsed 500 message text. |
Reads are no longer .lean() by default |
Your toJSON transforms now apply. Set lean: true to
restore 2.x speed — and check what those transforms were hiding. |
No import-time dotenv.config(); multer-s3 removed |
Load your own environment; uninstall multer-s3. |
The lean change is the one most likely to alter your responses.
2.x used .lean() on list endpoints, which bypasses
schema.set('toJSON', { transform }). If you stripped sensitive fields there,
2.x was leaking them on every list request and 3.0 stops. Compare a list response
before and after upgrading.
Constructor form
// Still supported, deprecated
new ControllerSets(Model, '-createdAt', ['category'], ['name']);
// Preferred — and required for any 3.0 option
new ControllerSets({ model: Model, orderBy: '-createdAt', query: ['category'] });
Every option
The complete list, with defaults.
Controller options
Accepted by createRouter, createRouterS3upload and the
ControllerSets constructor alike.
| Option | Type | Default | Description |
|---|---|---|---|
model |
Model | required | The Mongoose model these endpoints operate on. |
orderBy |
string | 'none' |
Default sort. Prefix with - for descending. Never allowlist-checked —
it's your choice. |
query |
string[] | [] |
Fields exposed as equality filters, and the fallback allowlist for filtering and sorting. |
search |
string | string[] | [] |
Fields searched by ?s=. Dot-notation reaches into
ref'd models. |
allowedFields |
string[] | {create, update} | unset | Fields a client may write. Unset means every schema field is writable, and logs a warning. |
blockedFields |
string[] | {create, update} | unset | Fields a client may never write. Applied after allowedFields. |
filterableFields |
string[] | query |
Fields usable with ?compareField= and ?rangeField=. |
sortableFields |
string[] | query + orderBy |
Fields usable with ?sort=. |
maxLimit |
number | 100 |
Hard cap on documents returned, paginated or not. Also caps
?pageSize=. |
maxSearchLength |
number | 128 |
Longest accepted search term. Longer terms return 400. |
allowRawRegex |
boolean | false |
Pass search terms to MongoDB unescaped. Unsafe for untrusted callers. |
lean |
boolean | false |
Return plain objects. Faster, but skips schema toJSON transforms. |
onGet |
function | 'none' |
Per-request populates and selects. Applies to reads and
to the document returned by PATCH. |
runAfterCreate |
function | 'none' |
Called with the created document, awaited before the 201. |
strictAfterCreate |
boolean | false |
Propagate runAfterCreate failures instead of logging them. |
logger |
{warn, error, debug?} | console |
Where warnings and hook failures go. Point it at your own logger. |
legacyMode |
boolean | false |
Restore 2.x behaviour: no field gating, raw regex, unbounded reads. Temporary. |
Router-only options
| Option | Where | Default | Description |
|---|---|---|---|
middlewares |
both factories | [] |
Express middlewares applied to every route in the router. |
path |
S3 router | 'files/' |
Key prefix inside the bucket. |
fields |
S3 router | [{name:'file',maxCount:1}] |
Multipart file fields. |
imgOptimizations |
S3 router | off | 'low' | 'medium' | 'med' | 'high'. |
upload |
S3 router | {} |
ACL, MIME allowlist and limits — see Upload options. |
Exports
| Export | Type | Purpose |
|---|---|---|
createRouter(options) |
function | Five CRUD endpoints for a model. |
createRouterS3upload(options) |
function | The same, with S3 uploads on POST and PATCH. |
ControllerSets |
class | The handlers themselves, for your own routes. |
errorHandler |
middleware | Consistent JSON errors. Mount last. |
fileUploadMiddleware(req,res,next,options) |
middleware | Uploads without the CRUD routes. |
compressImage(buffer,type,level) |
async | Re-encode an image buffer toward a target size. |
HttpError |
class | An error carrying a status whose message is safe to return. |
escapeRegex(value) |
function | Escape regex metacharacters in your own queries. |
Every query parameter
Everything GET / understands.
| Parameter | Example | Notes |
|---|---|---|
page |
?page=2 |
Switches on the pagination block. Starts at 1; junk values become 1.
|
pageSize |
?pageSize=20 |
Default 50, clamped to maxLimit. |
s / search |
?s=laptop |
Case-insensitive, matched literally, across the search fields. Max 128
characters. |
sort |
?sort=-price |
Must be in sortableFields, else 400. - means descending.
|
any field named in query |
?category=65af… |
Equality filter. Repeat the parameter for an $in match. |
rangeField + range |
?rangeField=price&range=10-100 |
Inclusive bounds; either side may be omitted. Field must be in
filterableFields. |
compareField + compareValue |
?compareField=price&compareValue=50 |
Field must be in filterableFields. Numeric-looking values become
numbers. |
compareOperator |
?compareOperator=gt |
gt, gte, lt, lte,
ne, eq. Defaults to eq. |
Putting several together
# featured laptops between 500 and 1500, cheapest first, second page of 20
GET /api/products
?isFeatured=true
&s=laptop
&rangeField=price&range=500-1500
&sort=price
&page=2&pageSize=20
Filters combine with AND. The search term is the one exception: it becomes an OR across your
search fields, and that whole group is ANDed with everything else.
Response shapes
Four shapes, and success is always there to branch on.
// GET /api/products
{
"success": true,
"data": [ { "_id": "65af…", "name": "Laptop", "price": 1299 } ]
}
// GET /api/products?page=2&pageSize=20
{
"success": true,
"data": [ /* … */ ],
"pagination": {
"currentPage": 2,
"pageSize": 20,
"totalPages": 7,
"totalRecords": 132
}
}
// GET /api/products/65af… · POST /api/products · PATCH /api/products/65af…
{
"success": true,
"data": { "_id": "65af…", "name": "Laptop", "price": 1299 }
}
// DELETE /api/products/65af…
{ "success": true, "message": "Item successfully deleted." }
// Any failure
{ "success": false, "error": "Entry not found.", "requestId": "0f2a…" }
requestId appears on errors that pass through errorHandler. Errors
answered directly by the controller carry success and error
only.
const res = await fetch('/api/products?page=1');
const body = await res.json();
if (!body.success) throw new Error(body.error);
body.data; // always an array here
body.pagination.totalPages; // present because ?page was sent
TypeScript
Type declarations ship with the package. Nothing to install.
import express from 'express';
import { createRouter, errorHandler, type RouterOptions } from 'express-controller-sets';
import Product, { type IProduct } from './models/Product.js';
const options: RouterOptions<IProduct> = {
model: Product,
orderBy: '-createdAt',
query: ['category'],
allowedFields: ['name', 'price'],
onGet: async (req, res) => ({ populates: 'category', selects: 'name price' }),
};
const app = express();
app.use('/api/products', createRouter(options));
app.use(errorHandler);
Exported types you'll reach for: ControllerOptions, RouterOptions,
RouterS3Options, UploadOptions, UploadField,
OnGetFn, OnGetResult, FieldPolicy,
ImageOptimizationLevel and Logger.
Types don't validate field names. allowedFields is
string[], so a typo — 'pirce' — compiles happily and silently
drops the real field at runtime. If a field refuses to save, check the spelling in your
allowlist first.
Custom routes
When the five generated endpoints aren't quite the shape you need.
Use the handlers on your own router
Every controller method is a plain Express handler, so you can mount them individually — different middleware per verb, extra routes alongside, whatever you like.
import express from 'express';
import { ControllerSets } from 'express-controller-sets';
import Product from './models/Product.js';
const controller = new ControllerSets({
model: Product,
query: ['category'],
allowedFields: ['name', 'price'],
});
const router = express.Router();
router.get('/', controller.getAll); // public
router.get('/:id', controller.getById); // public
router.post('/', requireAuth, controller.create); // staff only
router.patch('/:id', requireAuth, controller.update);
router.delete('/:id', requireAuth, requireAdmin, controller.delete);
// …and add whatever the generated set doesn't cover
router.get('/:id/related', myOwnHandler);
export default router;
Add routes to a generated router
A generated router is an ordinary Express router. Mount your own routes on the same path — declare
the more specific ones first so they aren't captured by /:id.
const extras = express.Router();
extras.get('/featured', listFeatured); // would otherwise hit /:id
app.use('/api/products', extras);
app.use('/api/products', createRouter({ model: Product }));
Uploads without CRUD
import { fileUploadMiddleware } from 'express-controller-sets';
const upload = (req, res, next) => fileUploadMiddleware(req, res, next, {
uploadPath: 'invoices/',
fields: [{ name: 'invoice', maxCount: 1 }],
allowedMimeTypes: ['application/pdf'],
maxFileSize: 2 * 1024 * 1024,
});
app.post('/api/invoices', requireAuth, upload, (req, res) => {
res.json({ url: req.body.invoice }); // already uploaded and validated
});
Common questions
The things people hit in the first hour.
My console shows a warning about allowedFields. What is it?
It means that model's routes will write any field a client sends, including ones
like role or isAdmin. Add allowedFields (or
blockedFields) and it goes away. It prints once per model per process, not per
request.
My POST returns 400 "Request body contains no writable fields."
Everything you sent was filtered out. Usual causes: the field names aren't in
allowedFields, you sent only immutable fields like _id, or you
forgot app.use(express.json()) so the body was never parsed at all. On an
upload router, also check the multipart field name matches your fields
entry.
Why does ?sort=name return 400?
name isn't in sortableFields. If you never set that option, it
defaults to your query array plus whatever orderBy names — which
usually doesn't include name. Add it explicitly.
Why do I only get 100 records back?
maxLimit. Unpaginated reads are capped so one request can't pull an entire
collection into memory. Use ?page=, or raise the cap with
maxLimit: 500 if you know the collection stays small.
Does this handle authentication?
No, and deliberately so. Every generated endpoint is public until you pass your own guards
in middlewares. See Auth & middlewares,
including the pattern for scoping records to the logged-in user.
The uploaded file URL returns 403 in the browser.
Uploads default to a private ACL, so the URL isn't publicly fetchable. Either
serve the object through a presigned URL or a CDN with origin access (use
file.key), or opt in with upload: { acl: 'public-read' } knowing
it's readable by anyone with the link.
My SVG upload is rejected.
SVG is active content — it can carry scripts — so it isn't on the default MIME allowlist.
You can add it to allowedMimeTypes, but it will still be stored
Content-Disposition: attachment so a browser downloads rather than renders it.
For icons, converting to PNG or WebP before upload is usually the better answer.
Do I need sharp and the AWS SDK if I only use CRUD?
No. They're optional and imported lazily — nothing loads until an upload router actually handles a file. A CRUD-only app never pays for the native binary or the SDK.
Can I use this from CommonJS (require)?
Not directly — the package is ESM only. In a CommonJS project use a dynamic import:
const cs = await import('express-controller-sets').
Is PUT supported?
No. Updates are PATCH only and use $set, so omitted fields are
left alone rather than wiped. If a client must send PUT, add your own route
pointing at controller.update.
Can one router serve two models, or two routers one model?
One router, one model. Two routers on one model is not only allowed but recommended — a public read-only one and a guarded admin one, each with its own allowlists. See Auth & middlewares.
How do I filter on a field the client shouldn't choose?
Set it in a middleware before the controller runs — write to req.query and
include the field in query. The client can send its own value, but yours
overwrites it because your middleware runs last.
Why did my search return nothing when the term clearly exists?
Two likely causes. If you searched a relational field like category.name, the
first segment must be a real ref in your schema — otherwise that clause is
dropped, and if every clause drops the result is deliberately empty. Second, terms are
matched literally, so regex syntax in the term matches nothing rather than acting as a
pattern.
Is legacyMode safe to leave on?
No. It disables field gating, restores raw regex and removes the read cap — every vulnerability 3.0 fixed. It exists so you can deploy an upgrade today and tighten options one at a time, and it's scheduled for removal in 4.0.