Custom routes
Use the ControllerSets handlers on routes you wire yourself.
When to wire routes yourself#
createRouter is a thin layer over ControllerSets. Build the controller directly when the generated router doesn't fit:
- Different guards per verb on one path — public reads, admin writes — without a second mount path.
- Only some of the six routes — say, no
DELETE. - Different paths or verbs —
PUT,/:id/publish. - Your own logic before a handler — ownership checks, feature flags.
import { ControllerSets } from 'express-controller-sets';
const products = new ControllerSets({
model: Product, // same options as createRouter, minus middlewares/enableQuery/upload
query: ['category'],
search: ['name'],
allowedFields: ['name', 'price', 'category'],
});
The six handlers#
| Handler | Usual route | Reads | Responds |
|---|---|---|---|
getAll | GET / | req.query | 200 list (+ pagination) |
query | QUERY / | req.body (JSON) | 200 list (+ pagination) |
get | GET /:id | req.params.id | 200 record · 400 bad id · 404 |
create | POST / | req.body | 201 record · 400 · 409 |
update | PATCH /:id | req.params.id, req.body | 200 record · 400 · 404 · 409 |
delete | DELETE /:id | req.params.id | 200 message · 404 |
- Handlers are pre-bound: pass them directly,
router.get('/', products.getAll). - The route parameter must be named
:id. - Each handler sends the response itself. To change what a read returns, use
onGetor a schematoJSON. createandupdateapplyallowedFieldsandvalidateexactly as on a generated router.
A router by hand#
All six handlers with a different guard per route, plus an extra endpoint and a PUT:
import express from 'express';
import { ControllerSets, errorHandler, isQueryMethodSupported } from 'express-controller-sets';
const products = new ControllerSets({
model: Product,
query: ['category'],
search: ['name'],
sortableFields: ['price', 'createdAt'],
allowedFields: ['name', 'price', 'category'],
});
const router = express.Router();
const admin = [auth.requireAuth, auth.requireRole('admin')];
router.get('/', products.getAll); // public
if (isQueryMethodSupported()) router.query('/', products.query); // public; Node 22.2+ only
router.get('/featured', async (req, res) => { // your own route — before '/:id'
res.json({ success: true, data: await Product.find({ featured: true }).limit(10) });
});
router.get('/:id', products.get); // public
router.post('/', ...admin, products.create); // admin only
router.patch('/:id', ...admin, products.update);
router.put('/:id', ...admin, products.update); // PUT, if clients need it — still a partial update
router.delete('/:id', ...admin, products.delete);
app.use('/api/products', router);
app.use(errorHandler);
Guard router.query(). It only exists on runtimes that know the method; on older Node, calling it throws at startup. createRouter checks for you — wiring it yourself means checking with isQueryMethodSupported().
Add logic before a handler#
Run your own middleware, then hand over:
import mongoose from 'mongoose';
import { HttpError } from 'express-controller-sets';
// Only the owner may change or delete a note
const ownsNote = async (req, res, next) => {
if (!mongoose.isValidObjectId(req.params.id)) return next(); // the handler answers 400
const mine = await Note.exists({ _id: req.params.id, ownerId: req.auth.userId });
if (!mine) throw new HttpError(404, 'Entry not found.');
next();
};
router.patch('/:id', auth.requireAuth, ownsNote, notes.update);
router.delete('/:id', auth.requireAuth, ownsNote, notes.delete);
Throwing HttpError from async middleware works on Express 5 — it reaches errorHandler, which sends the status and message. The full per-user pattern is in Protecting routes.
Other members#
| Member | What it gives you |
|---|---|
controller.config | The resolved, frozen options — every default filled in. |
controller.invalidateCache() | Drop this model's cached responses. Resolves quietly when caching is off. Caching |
controller.getPopulates(req, res) | What onGet resolves to for this request: { populates, selects }. |
controller.getPaginatedResults(req, res, filters, sort, populates?, selects?) | One offset-paginated read of your filter, sent as a standard paginated response. Reads page and pageSize from req.query. |
// A custom list endpoint with the standard response and pagination
router.get('/on-sale', (req, res) =>
products.getPaginatedResults(req, res, { discount: { $gt: 0 } }, { discount: -1 }));
How the handlers are built#
For contributors, and for anyone writing an endpoint the library doesn't generate. Each handler is a short composition of shared modules under src/core/, so a rule enforced in one applies everywhere.
| Module | Responsibility |
|---|---|
config.js | Options in, one frozen config out. Every default is resolved here, once. |
getAll.js · query.js · get.js · create.js · update.js · delete.js | One module per route. |
readParams.js | Request → filters, sort, page window. Pure. The only difference between GET and QUERY. |
list.js · cursor.js | The shared list retrieval: offset pages, keyset pages, plain lists. |
search.js | Escaped search terms, including across referenced models. |
write.js | Body → payload: the field policy, then your validator. |
dataAccess.js | Every Mongoose call, with maxLimit, maxTimeMS, lean and onGet applied. |
respond.js | The response envelope, so no endpoint invents its own shape. |