createRouter
Every option of createRouter — including file uploads — why you would use it, and every API endpoint it creates.
Options available in createRouter#
Every option createRouter accepts. Only model is required — start with model and allowedFields, and add the rest as you need them. Each one is explained below. new ControllerSets() takes the same options except middlewares, enableQuery and upload (see Custom routes).
createRouter({
// ── Model & response ──
// required — the Mongoose model to serve
model: Product,
// default sort for lists ('-' = descending)
orderBy: '-createdAt',
// per-request populate & select on reads
onGet: (req, res) => ({ populates: 'category', selects: '-secret' }),
// true = plain objects: faster, but skips toJSON transforms
lean: false,
// where warnings go (pino, winston…)
logger: console,
// ── Filtering, search & sorting ──
// exact-match filters: ?category=chairs
query: ['category', 'inStock'],
// fields ?s= searches; dotted = referenced model
search: ['name', 'category.name'],
// range/compare + QUERY filter fields (default: query)
filterableFields: ['price', 'category'],
// fields ?sort= accepts (default: query + orderBy)
sortableFields: ['price', 'name', 'createdAt'],
// longer search terms are a 400
maxSearchLength: 128,
// cap on ids a relational search pulls
maxRelationMatches: 1000,
// true = search terms run as regex — trusted callers only
allowRawRegex: false,
// ── Pagination & limits ──
// 'offset' (page numbers) or 'cursor' (nextCursor)
pagination: 'offset',
// page size when the client sends none
defaultPageSize: 50,
// most documents any response returns
maxLimit: 100,
// deepest offset page allowed (default: off)
maxPage: 500,
// 'exact' | 'estimated' | 'none' — how totals are counted
countStrategy: 'exact',
// server-side time limit per query (default: off)
maxTimeMS: 5000,
// allow sorts over MongoDB's 100 MB memory limit
allowDiskUse: false,
// documents per driver round trip (default: driver's)
batchSize: 1000,
// ── Writing & validation ──
// fields clients may write — or { create, update }
allowedFields: ['name', 'price', 'category', 'inStock'],
// fields clients may never write (wins over allowedFields)
blockedFields: ['internalCode'],
// checks writes before saving — { create, update } or one function for both
validate: {
// runs on POST — return an object to replace the payload, throw ValidationError to reject
create: (payload, { req, res, operation, id, model }) => payload,
// runs on PATCH — same contract
update: (payload, ctx) => payload,
},
// side effect after a create, before the 201
runAfterCreate: async (doc) => {},
// true = a runAfterCreate failure becomes a 500
strictAfterCreate: false,
// ── Cache (Redis) ── or cache: true to use every default
cache: {
// seconds an entry lives (env CACHE_TTL)
ttl: 60,
// Redis connection URL (env REDIS_URL)
url: process.env.REDIS_URL,
// reuse an existing ioredis / node-redis client instead of url
client: undefined,
// any { get, set } store, e.g. createMemoryCacheStore()
store: undefined,
// key prefix, to share one Redis (env CACHE_PREFIX)
prefix: 'cs:',
// what else a response depends on (default: signed-in user)
vary: (req) => req.auth?.userId,
// give up on the cache and use the database after this
timeoutMs: 150,
// override the router part of the key (default: mount path)
namespace: undefined,
},
// ── Router ──
// guards run before every route (auth, rate limit…)
middlewares: [],
// mount the QUERY / route (needs Node 22.2+)
enableQuery: true,
// restore unsafe 2.x behaviour — migration only, removed in 4.0
legacyMode: false,
// ── File upload ── off when omitted; upload: true uses every default
upload: {
// folder (key prefix) in the bucket
path: 'products/',
// form fields that carry files (default: one field named 'file')
fields: [
// one file → saved as a URL string
{ name: 'cover', maxCount: 1 },
// several files → saved as an array of URLs
{ name: 'gallery', maxCount: 6 },
// saved as { url } instead of a string
{ name: 'manual', maxCount: 1, formatToUrlObject: true },
],
// 'private' or 'public-read' (URL opens in a browser)
acl: 'private',
// checked from the file's bytes
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
// bytes per file (default 10 MB)
maxFileSize: 10 * 1024 * 1024,
// files per request
maxFiles: 10,
// 'low' | 'medium' | 'high' compression (needs sharp; default off)
imgOptimizations: 'medium',
// true = clients pick the level — costs CPU
allowClientImageOptions: false,
},
});
The router it returns serves six routes: GET /, QUERY /, POST /, GET /:id, PATCH /:id and DELETE /:id — see API endpoints.
Model & response#
| Option | Default | Why use it |
|---|---|---|
model | required | Tells the router which collection to serve. Everything else is optional. |
orderBy | none | Gives lists a sensible default order — usually newest first — so clients don't have to ask for one. |
onGet | none | Return related data (populate a ref) or hide heavy or private fields, without writing a custom route. Runs on every read. |
lean | false | Faster reads and less memory on large lists, when you don't need schema virtuals or toJSON transforms. |
logger | console | Send the library's warnings to your own logger (pino, winston) instead of the console. |
Filtering, search & sorting#
| Option | Default | Why use it |
|---|---|---|
query | [] | Let clients narrow a list by exact value — ?category=chairs, ?status=paid. Fields not listed are ignored. |
search | [] | Power a search box across one or more text fields — including a referenced model, like 'author.name'. |
filterableFields | same as query | Allow ranges and comparisons (price between 50 and 200, rating above 4) and QUERY-body filters — only on fields you choose. |
sortableFields | query + orderBy | Let clients sort, while refusing sorts on unindexed fields that would be slow. |
maxSearchLength | 128 | Stop very long search strings from costing database time. |
maxRelationMatches | 1000 | Keep relational searches bounded as the referenced collection grows. |
allowRawRegex | false | Only for trusted internal tools that need regex search. Public input could slow the database down. |
createRouter({
model: Product,
query: ['category', 'inStock'],
search: ['name', 'description', 'brand.name'],
filterableFields: ['price', 'category'],
sortableFields: ['price', 'name', 'createdAt'],
allowedFields: ['name', 'price'],
});
Pagination & limits#
| Option | Default | Why use it |
|---|---|---|
pagination | 'offset' | Page numbers suit normal lists. Switch to 'cursor' for feeds and huge collections, where deep pages get slow. |
defaultPageSize | 50 | Match the page size your UI shows when the client doesn't send one. |
maxLimit | 100 | Cap response size so one request can't pull the whole collection into memory. |
maxPage | off | Block very deep offset pages, which force the database to skip huge numbers of records. |
countStrategy | 'exact' | Counting every match is the slow part of a big list. Use 'estimated' or 'none' to skip it. |
maxTimeMS | off | Stop a runaway query instead of letting it tie up the database. |
allowDiskUse | false | Allow sorts larger than MongoDB's 100 MB in-memory limit. |
batchSize | driver default | Fewer round trips when reading large result sets. |
createRouter({
model: Event,
orderBy: '-createdAt',
pagination: 'cursor', // clients follow pagination.nextCursor
defaultPageSize: 25,
maxLimit: 200,
maxTimeMS: 2000,
allowedFields: [],
});
Writing & validation#
| Option | Default | Why use it |
|---|---|---|
allowedFields | every field ⚠ | The most important setting: it stops clients writing fields like role, isAdmin or ownerId. Use { create, update } for different rules per verb. |
blockedFields | none | Deny a few fields while allowing the rest — handy on large models. |
validate | none | Business rules your schema can't express, and server-owned values (the author from the token). Throw ValidationError to reject. |
runAfterCreate | none | Side effects after a create — send an email, write an audit log, clear a cache — without a custom route. |
strictAfterCreate | false | Turn on when that side effect must succeed, or the request should fail. |
import { createRouter, ValidationError } from 'express-controller-sets';
createRouter({
model: Post,
allowedFields: { create: ['title', 'body', 'slug'], update: ['title', 'body'] },
blockedFields: ['views'],
validate: {
create: (payload, { req }) => {
if (!payload.title?.trim()) throw new ValidationError('Check the fields.', { title: 'is required' });
return { ...payload, author: req.auth.userId }; // server-owned field
},
},
runAfterCreate: async (post) => notifySubscribers(post),
});
Cache#
| Option | Default | Why use it |
|---|---|---|
cache | off | Answer repeated reads from Redis instead of the database. true uses REDIS_URL; writes clear it automatically. |
cache.ttl | 60 | How long a response stays cached, in seconds. |
cache.vary | the signed-in user | What else a response depends on, so different users never share entries. |
cache.store · cache.client · cache.url · cache.prefix · cache.timeoutMs | — | Where and how entries are stored — see Caching → Options. |
createRouter({ model: Product, cache: { ttl: 300 }, allowedFields: ['name', 'price'] });
Everything about caching: Redis cache.
Router options#
| Option | Default | Why use it |
|---|---|---|
middlewares | [] | Auth, rate limiting or logging for every route of this router. |
enableQuery | true | Turn off the QUERY route when you don't need it, or when a middleware scopes reads through req.query. |
legacyMode | false | Only while migrating a 2.x app. It removes the allowlists and limits. |
upload | off | Accept files on POST and PATCH and store them in S3 — see S3 uploads. |
The returned router also has router.invalidateCache() — see Caching.
// Anyone can read; only admins can write — two routers, one model
const readOnly = (req, res, next) =>
['GET', 'HEAD', 'OPTIONS', 'QUERY'].includes(req.method)
? next()
: res.status(405).json({ success: false, error: 'Method not allowed.' });
app.use('/api/products', createRouter({ model: Product, middlewares: [readOnly], allowedFields: [] }));
app.use('/api/admin/products', createRouter({
model: Product,
middlewares: [auth.requireAuth, auth.requireRole('admin')],
allowedFields: ['name', 'price'],
}));
allowedFields: [] stops POST and PATCH, but not DELETE /:id — it has no body to filter. A public router needs a guard like readOnly. More: Protecting routes.
S3 uploads#
Add upload to createRouter and POST and PATCH accept multipart/form-data; files go to your bucket and their URLs are saved on the record. Every option above still applies. upload: true takes every default; an object overrides the ones it names. Reads — GET and QUERY — never run the upload step.
createRouterS3upload is deprecated since 3.3.0 and is removed in 4.0. Move path, fields and imgOptimizations into upload and call createRouter — see MIGRATION.md.
npm install multer @aws-sdk/client-s3 # plus sharp for image compression
import { createRouter } from 'express-controller-sets';
createRouter({
// Every option above works here too, for example:
model: Product,
middlewares: [auth.requireAuth],
allowedFields: ['name', 'price', 'cover', 'gallery'], // include the file fields
upload: {
path: 'products/', // folder in the bucket
fields: [
{ name: 'cover', maxCount: 1 }, // saved as a URL string
{ name: 'gallery', maxCount: 6 }, // saved as an array of URLs
{ name: 'manual', maxCount: 1, formatToUrlObject: true }, // saved as { url }
],
imgOptimizations: 'medium', // 'low' | 'medium' | 'high' (needs sharp)
acl: 'private', // or 'public-read'
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
maxFileSize: 10 * 1024 * 1024, // per file, bytes
maxFiles: 10, // per request
allowClientImageOptions: false,
},
});
| Option | Default | Why use it |
|---|---|---|
upload.path | 'files/' | Keep each model's files in their own folder of the bucket. |
upload.fields | [{ name: 'file', maxCount: 1 }] | Name the form fields that carry files. maxCount: 1 saves a string; more saves an array. |
upload.fields[].formatToUrlObject | false | Save { url } instead of a string, when your schema stores file objects. |
upload.imgOptimizations | off | Shrink JPEG, PNG and WebP images before storing them — smaller bills, faster pages. |
upload.acl | 'private' | Private by default. 'public-read' when the URL should open in a browser, like product photos. |
upload.allowedMimeTypes | JPEG, PNG, GIF, WebP, AVIF, PDF | Accept only the file types you expect. Checked from the file bytes, not the client's claim. |
upload.maxFileSize | 10 MB | Stop oversized uploads before they reach your bucket. |
upload.maxFiles | 10 | Cap the number of files in one request. |
upload.allowClientImageOptions | false | Let clients choose the compression level. Costs CPU — keep it off on public routes. |
Sending files#
curl -X POST localhost:3000/api/products \
-F name=Chair -F price=120 \
-F cover=@cover.jpg \
-F gallery=@side.jpg -F gallery=@back.jpg
const form = new FormData();
form.append('name', 'Chair');
form.append('price', '120');
form.append('cover', coverInput.files[0]);
for (const file of galleryInput.files) form.append('gallery', file);
const res = await fetch('/api/products', { method: 'POST', body: form }); // no Content-Type header
const { data } = await res.json();
console.log(data.cover, data.gallery);
{ "success": true,
"data": { "_id": "65af…", "name": "Chair", "price": 120,
"cover": "https://s3.us-east-1.amazonaws.com/acme-uploads/products/1726…-51234.jpg",
"gallery": ["https://…/products/1726…-11.jpg", "https://…/products/1726…-12.jpg"] } }
Uploading outside a CRUD router, connecting your bucket, and how files are checked: File uploads.
API endpoints#
What a client (web app, mobile app, curl) can call on any router. Examples use /api/products.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/products | — | 200 list |
| QUERY | /api/products | JSON filters | 200 list |
| POST | /api/products | JSON record | 201 record |
| GET | /api/products/:id | — | 200 record |
| PATCH | /api/products/:id | JSON fields to change | 200 record |
| DELETE | /api/products/:id | — | 200 message |
A parameter only works on fields the server allows — in query, search, filterableFields or sortableFields. An equality filter on any other field is ignored; a range, compare or sort on one is a 400 naming the field. See Filtering, search & sorting.
| Client wants to… | Send | Server option |
|---|---|---|
| Filter | ?category=chairs (repeat for any-of) | query |
| Range / compare | ?rangeField=price&range=50-200 · ?compareField=price&compareOperator=gt&compareValue=100 | filterableFields |
| Search | ?s=oak | search |
| Sort | ?sort=-price | sortableFields |
| Paginate | ?page=2&pageSize=20 · or ?cursor=… | pagination |
| Complex read | QUERY / with a JSON body | filterableFields, sortableFields |
Every parameter, the QUERY body, response shapes and status codes: HTTP API.
Filtering & search in depth#
A parameter for a field you didn't list is ignored if it's an equality filter, and a 400 naming the field if it's a range, comparison or sort — so a client typo in a sort never silently returns the wrong order. The full parameter syntax clients use is in HTTP API → Filtering.
Why filterable fields are allowlisted#
Suppose your schema has passwordResetToken and your API never returns it. If a client could send ?compareField=passwordResetToken&compareOperator=gt&compareValue=m, the number of results answers "does the token sort after m?" — and a few hundred such requests rebuild the token character by character. The value never appears in a response; it leaks through the count. Allowlisting closes that.
Searching a referenced model#
A search field with a dot is split at the first dot. The part before must be a field declared with ref; the rest is the path on the referenced model. The library finds matching documents there, then filters your collection by their ids — no aggregation pipeline.
| Search field | Result |
|---|---|
category.name | Searches name on the Category model |
author.profile.name | Searches profile.name on the Author model |
notARef.name | Clause dropped — the field has no ref |
If a relational clause matches nothing, the result is empty, not "search ignored" — otherwise a search for something nobody sells would return the whole catalogue. At most maxRelationMatches (1000) ids are pulled from the referenced collection.
Search terms are text, not patterns#
Regex metacharacters are escaped, so ?s=c++ finds the literal string. An unescaped term is a program the client writes and your database runs: ?s=(a+)+$ triggers catastrophic backtracking inside mongod. Terms over maxSearchLength (128) are rejected with 400. If trusted internal tools truly need patterns, allowRawRegex: true exists — behind authentication only.
How writes are filtered#
The request body is filtered before Mongoose sees it. This is the most important setting for security.
createRouter({ model: User, allowedFields: ['name', 'email', 'avatar'] });
Name what is writable. A field added to the schema later stays closed until you add it here.
createRouter({ model: User, blockedFields: ['role', 'isAdmin', 'balance'] });
Name what is not writable. Handy on large models, but a field added later is open by default. Combined with allowedFields, the block wins.
createRouter({
model: User,
allowedFields: {
create: ['name', 'email', 'password'],
update: ['name', 'avatar'], // email and password need their own flow
},
});
Different rules for POST and PATCH. Works for blockedFields too.
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" // ✘ MongoDB syntax → dropped
}
// What reaches the database:
{ "name": "Ayesha", "email": "ayesha@example.com" }
Dropped fields are dropped quietly and the request succeeds with what's left. If nothing is left, the response is 400 "Request body contains no writable fields." rather than an empty record.
Mongoose strict mode is not a substitute. Strict mode drops keys the schema doesn't define — it discards typos and faithfully saves {"role": "admin"}, because role is a real field.
A field "won't save"? It's almost always missing from allowedFields, or misspelled there — TypeScript types it as string[], so 'pirce' compiles fine.
Everything about validate#
validate is for rules your schema can't express, and for setting server-owned values. Three gates run in order, each answering a different question:
1. Field policy
allowedFields: may this client set this field?
2. Your validator
validate: do these values make sense?
3. The schema
Mongoose: types, required, enum, custom validators.
Because your hook receives the payload after the field policy, it never has to wonder whether role came from an admin or a stranger — it can't be there.
import { createRouter, ValidationError } from 'express-controller-sets';
createRouter({
model: Product,
middlewares: [auth.requireAuth],
allowedFields: { create: ['name', 'price', 'sku'], update: ['name', 'price'] }, // sku is set once
validate: {
create: async (payload, { req, model }) => {
if (payload.price !== undefined && !(payload.price >= 0)) {
throw new ValidationError('Check the submitted values.', { price: 'must be zero or more' });
}
if (await model.exists({ sku: payload.sku })) {
throw new ValidationError('That SKU is taken.', { sku: 'already in use' });
}
// Returning an object replaces the payload: normalise, and set server-owned fields.
return {
...payload,
...(payload.name ? { name: payload.name.trim() } : {}),
ownerId: req.auth.userId,
};
},
update: (payload) => {
if (payload.price === 0) throw new ValidationError('Price cannot be zero.');
},
},
});
Pass a single function instead of { create, update } to run the same rules on both.
What the hook receives#
| Argument | Holds |
|---|---|
payload | The body, already filtered to writable fields. Never empty — an empty one is a 400 before the hook runs. |
context.req · context.res | The Express request and response — req.auth, headers, uploaded files. |
context.operation | 'create' or 'update'. |
context.id | The target document's id on update; undefined on create. |
context.model | The Mongoose model. |
What it may return or throw#
| The hook… | Result |
|---|---|
| returns nothing | The filtered payload is saved as-is. |
| returns an object | That object is saved instead. $-prefixed, dotted and __proto__ keys are still stripped from it. |
throws ValidationError(message, fields?, status?) | 400 (or status) with a fields map. |
throws HttpError(status, message) | That status and message — e.g. 403. |
| throws anything else | 500; the detail is logged, not sent. |
is async | Awaited — database checks are fine. |
The client sees:
// 400 Bad Request
{ "success": false, "error": "Check the submitted values.", "fields": { "price": "must be zero or more" } }
Mongoose validation failures and duplicate-key conflicts fill fields the same way, so one rendering path on the client covers every rejected write.
Your hook runs before the schema, so required fields may be missing. That's what lets it supply a required ownerId. The corollary: payload.name.trim() throws when the client omitted name, and an unexpected throw is a 500. Check before you dereference — and on update, every field is optional.
Everything about runAfterCreate#
runAfterCreate receives the created document and is awaited before the 201 is sent. Use it to send a welcome email, enqueue a job or write an audit log.
createRouter({
model: User,
allowedFields: ['name', 'email'],
runAfterCreate: async (user) => {
await queue.add('send-welcome', { id: user._id });
},
});
Failures are logged, not raised
The record exists, so the client still gets 201. Reporting failure would invite a retry of a write that already landed.
Failures propagate
The client sees a 500 — even though the record was created. Only when a missed side effect is worse than a confusing response.
The client is waiting on this hook. Enqueue slow or failure-prone work instead of doing it inline.
Everything about onGet#
onGet decides, per request, which references to populate and which fields to select. It receives the live req, so it can depend on the caller or the route.
onGet: (req, res) => ({
populates: [], // anything Mongoose .populate() accepts: string, object or array
selects: '', // anything Mongoose .select() accepts: 'name price -_id'
})
createRouter({
model: Product,
onGet: () => ({ populates: 'category', selects: 'name price category' }),
});
Keep lists light; expand on the single-record route.
onGet: (req) => {
const isDetail = Boolean(req.params.id);
return {
populates: isDetail ? ['category', 'reviews'] : 'category',
selects: isDetail ? '' : 'name price category',
};
},
onGet: (req) => req.auth?.role === 'admin'
? { populates: [{ path: 'customer', select: 'name email phone' }], selects: '' }
: { populates: 'items.product', selects: '-internalNotes -margin' },
| Route | onGet applies? |
|---|---|
GET / · QUERY / · GET /:id | ✔ |
PATCH /:id — the returned document | ✔ |
POST / — returns the created document as-is | ✘ |
DELETE /:id — returns a message | ✘ |
If onGet throws, the read still succeeds — with no populate and no select. That keeps a broken hook from taking down every read, but a hook you rely on to hide fields would silently stop hiding them. For fields that must never leave the server, use select: false or a toJSON transform in the schema.
lean. Reads return hydrated Mongoose documents by default, so schema toJSON transforms run. lean: true returns plain objects — faster and lighter, but those transforms are skipped. Enable it only after checking nothing sensitive depends on them.
Pagination in depth#
Two modes. Pick by how clients browse the data.
Page numbers with totals
?page=4&pageSize=20, and responses carry totalPages. Right for admin tables and anything with a page picker. Gets slower on deep pages of large collections.
"Next page" tokens
Clients follow nextCursor. One index seek per page at any depth, no count, no duplicates when records are inserted mid-scan. Right for feeds, infinite scroll and exports.
// Schema: eventSchema.index({ createdAt: -1, _id: -1 });
createRouter({
model: Event,
orderBy: '-createdAt',
pagination: 'cursor',
defaultPageSize: 25,
maxTimeMS: 2000, // a slow query is cut off, not left running
allowedFields: [],
});
Why offsets slow down#
?page=4000 asks MongoDB to walk and discard the 199,950 index entries before it — and the countDocuments that produces totals scans every match. Measured on 200,000 documents, 50 per page:
| page 1 | page 1,000 | page 4,000 | |
|---|---|---|---|
'offset' | 18.7 ms | 19.1 ms | 47.8 ms |
'cursor' | 3.6 ms | 0.6 ms | 0.9 ms |
Most of the offset cost is the count, which is why page 1 costs as much as page 1,000. Need page numbers but not totals? Keep offset mode and set countStrategy: 'none' — responses carry hasMore instead of totalPages.
How cursors behave#
- Ties are handled.
_idis appended to your sort automatically, so records sharing a sort value are never skipped or repeated at page boundaries. - A cursor carries an id, not values. The server re-reads the anchor record, so a hand-crafted cursor can't range-filter a field you never made filterable.
- A cursor is tied to its sort. Reusing one with a different
sort, or after its anchor record was deleted, is a 400 — restart the scan. - Modes don't mix.
?page=in cursor mode or?cursor=in offset mode is a 400.
None of this replaces an index. A cursor page is one index seek only if the sort is indexed — { price: 1, _id: 1 } for ?sort=price. Without it MongoDB sorts in memory and no option here saves it.
Add your own endpoints alongside#
A generated router is an ordinary Express router. Put your routes on the same path, before it, so /:id doesn't capture them:
const products = express.Router();
products.get('/stats', async (req, res) => {
const [row] = await Product.aggregate([{ $group: { _id: null, avg: { $avg: '$price' }, n: { $sum: 1 } } }]);
res.json({ success: true, data: row ?? { avg: 0, n: 0 } });
});
products.use(createRouter({ model: Product, allowedFields: ['name', 'price'] }));
app.use('/api/products', products); // GET /api/products/stats + the six generated routes
Need a different guard per verb, a PUT, or only some routes? Wire the handlers yourself: Custom routes.