controller-sets v3.3.0

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#

OptionDefaultWhy use it
modelrequiredTells the router which collection to serve. Everything else is optional.
orderBynoneGives lists a sensible default order — usually newest first — so clients don't have to ask for one.
onGetnoneReturn related data (populate a ref) or hide heavy or private fields, without writing a custom route. Runs on every read.
leanfalseFaster reads and less memory on large lists, when you don't need schema virtuals or toJSON transforms.
loggerconsoleSend the library's warnings to your own logger (pino, winston) instead of the console.

Filtering, search & sorting#

OptionDefaultWhy 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'.
filterableFieldssame as queryAllow ranges and comparisons (price between 50 and 200, rating above 4) and QUERY-body filters — only on fields you choose.
sortableFieldsquery + orderByLet clients sort, while refusing sorts on unindexed fields that would be slow.
maxSearchLength128Stop very long search strings from costing database time.
maxRelationMatches1000Keep relational searches bounded as the referenced collection grows.
allowRawRegexfalseOnly 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#

OptionDefaultWhy use it
pagination'offset'Page numbers suit normal lists. Switch to 'cursor' for feeds and huge collections, where deep pages get slow.
defaultPageSize50Match the page size your UI shows when the client doesn't send one.
maxLimit100Cap response size so one request can't pull the whole collection into memory.
maxPageoffBlock 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.
maxTimeMSoffStop a runaway query instead of letting it tie up the database.
allowDiskUsefalseAllow sorts larger than MongoDB's 100 MB in-memory limit.
batchSizedriver defaultFewer 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#

OptionDefaultWhy use it
allowedFieldsevery field ⚠The most important setting: it stops clients writing fields like role, isAdmin or ownerId. Use { create, update } for different rules per verb.
blockedFieldsnoneDeny a few fields while allowing the rest — handy on large models.
validatenoneBusiness rules your schema can't express, and server-owned values (the author from the token). Throw ValidationError to reject.
runAfterCreatenoneSide effects after a create — send an email, write an audit log, clear a cache — without a custom route.
strictAfterCreatefalseTurn 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#

OptionDefaultWhy use it
cacheoffAnswer repeated reads from Redis instead of the database. true uses REDIS_URL; writes clear it automatically.
cache.ttl60How long a response stays cached, in seconds.
cache.varythe signed-in userWhat 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#

OptionDefaultWhy use it
middlewares[]Auth, rate limiting or logging for every route of this router.
enableQuerytrueTurn off the QUERY route when you don't need it, or when a middleware scopes reads through req.query.
legacyModefalseOnly while migrating a 2.x app. It removes the allowlists and limits.
uploadoffAccept 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,
  },
});
OptionDefaultWhy 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[].formatToUrlObjectfalseSave { url } instead of a string, when your schema stores file objects.
upload.imgOptimizationsoffShrink 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.allowedMimeTypesJPEG, PNG, GIF, WebP, AVIF, PDFAccept only the file types you expect. Checked from the file bytes, not the client's claim.
upload.maxFileSize10 MBStop oversized uploads before they reach your bucket.
upload.maxFiles10Cap the number of files in one request.
upload.allowClientImageOptionsfalseLet 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.

MethodPathBodyResponse
GET/api/products—200 list
QUERY/api/productsJSON filters200 list
POST/api/productsJSON record201 record
GET/api/products/:id—200 record
PATCH/api/products/:idJSON fields to change200 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…SendServer option
Filter?category=chairs (repeat for any-of)query
Range / compare?rangeField=price&range=50-200 · ?compareField=price&compareOperator=gt&compareValue=100filterableFields
Search?s=oaksearch
Sort?sort=-pricesortableFields
Paginate?page=2&pageSize=20 · or ?cursor=…pagination
Complex readQUERY / with a JSON bodyfilterableFields, 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 fieldResult
category.nameSearches name on the Category model
author.profile.nameSearches profile.name on the Author model
notARef.nameClause 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#

ArgumentHolds
payloadThe body, already filtered to writable fields. Never empty — an empty one is a 400 before the hook runs.
context.req · context.resThe Express request and response — req.auth, headers, uploaded files.
context.operation'create' or 'update'.
context.idThe target document's id on update; undefined on create.
context.modelThe Mongoose model.

What it may return or throw#

The hook…Result
returns nothingThe filtered payload is saved as-is.
returns an objectThat 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 else500; the detail is logged, not sent.
is asyncAwaited — 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 });
  },
});
Default

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.

strictAfterCreate: true

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' },
RouteonGet 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.

pagination: 'offset' · default

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.

pagination: 'cursor'

"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 1page 1,000page 4,000
'offset'18.7 ms19.1 ms47.8 ms
'cursor'3.6 ms0.6 ms0.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. _id is 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.

Esc
↑ ↓ to move↵ to open