Troubleshooting
Symptoms, causes and fixes for the problems people hit first.
Check these first#
Most problems are one of these five. Each takes seconds to rule out.
app.use(express.json())is mounted before your routers. Without it, every body is empty.app.use(errorHandler)is mounted after them. Without it, unexpected errors get Express's default HTML page.- The field is in the right allowlist —
allowedFieldsto write,queryto filter,filterableFieldsto range or compare,sortableFieldsto sort,searchto search. Check the spelling against the schema. - Read the error message. 400s name the field or parameter that failed. 500s carry a
requestIdthat matches a line in your server log. - Check the startup log. The library warns once about unprotected models, a skipped
QUERYroute, and misplaced options.
Writes#
POST returns 400 "Request body contains no writable fields."#
Everything sent was filtered out. Causes, most likely first: the fields aren't in allowedFields; express.json() isn't mounted so the body was never parsed; the client sent only immutable fields like _id; on an upload router, the multipart field names don't match upload.fields.
A field silently doesn't save#
It isn't in allowedFields (or it is in blockedFields, which wins), or it's misspelled there — TypeScript won't catch it. On a per-verb policy, check the right verb: { create: [...], update: [...] }. For files, the field must be writable too.
Startup warning: "has no allowedFields/blockedFields configured"#
That model's routes will write any schema field a client sends, including role or isAdmin. Add allowedFields. It prints once per model per process.
My validate hook causes a 500#
Usually a dereference of a field the client didn't send — payload.name.trim() when name is absent. The hook runs before the schema, so required fields may be missing, and on update every field is optional. To reject deliberately, throw ValidationError or HttpError; anything else is a 500.
A duplicate value returns 409#
Working as intended: a unique index rejected the write. The message and fields name the conflicting field, so the client can show "that email is already registered".
Is PUT supported?#
Not on generated routers — updates are PATCH and change only the fields sent. If a client needs PUT, add router.put('/:id', controller.update) on a hand-wired router.
Reads#
I only get 100 records#
maxLimit caps every response so one request can't load a whole collection into memory. Paginate with ?page=, or raise it deliberately: maxLimit: 500.
A filter parameter is ignored#
Equality parameters for fields not in query are ignored rather than rejected. Add the field to query.
?sort=name returns 400#
name isn't in sortableFields. When unset, it defaults to your query fields plus the orderBy field — which rarely includes name. List it explicitly.
A range or compare returns 400#
The field isn't in filterableFields. When unset it equals query; once you set it, it replaces that default, so include every field you want filterable.
Search returns nothing when the term clearly exists#
For a relational field like category.name, the part before the dot must be a schema field with ref, or the clause is dropped — and if every clause drops, the result is deliberately empty. Also, terms match literally: regex syntax in the term matches nothing.
A field I hide with onGet sometimes appears#
If onGet throws, the read falls back to no select. Use select: false in the schema or a toJSON transform for anything that must never be returned — and keep lean off if you rely on toJSON.
How do I filter on a value the client must not choose?#
Set it in middleware. On Express 5 you can't assign to req.query — it's re-parsed on every read — so shadow it with Object.defineProperty, and set enableQuery: false so QUERY can't route around it. Full pattern: Users see only their own records.
QUERY requests#
QUERY returns 404#
In order of likelihood: the server runs Node older than 22.2, so the route wasn't mounted (check isQueryMethodSupported() and the startup warning); the request went to /:id — QUERY is collection-only; or a proxy, CDN or gateway refused the method before it reached the app.
QUERY returns everything, or a 400 I didn't expect#
Everything: no filter arrived — the only filter key is filter, and misspelled top-level keys are 400s, so a 200 means the body was empty or unparsed. A 400: the message names the field or operator; usually a field missing from filterableFields / sortableFields, $gte instead of gte, or "page": "2" as a string.
QUERY returns 415#
The body wasn't JSON (Content-Type isn't application/json), or no JSON parser is mounted on the server.
When should I use QUERY instead of GET?#
When the parameters don't fit a URL: long id lists, several conditions per field, multi-key sorts, or filters you'd rather keep out of logs. For ordinary lists and shareable links, GET is cacheable and understood by everything. Both run identical code.
Uploads#
Upload routes return 503#
S3 isn't configured. The server log names the missing variables — usually the environment wasn't loaded. The library never calls dotenv; add import 'dotenv/config' as your first import or run node --env-file=.env.
The uploaded file URL returns 403 in the browser#
Uploads are private by default. Serve them through presigned URLs or a CDN with origin access, or set upload: { acl: 'public-read' } for content meant to be public.
An SVG (or other file type) is rejected#
Only JPEG, PNG, GIF, WebP, AVIF and PDF are allowed by default. Add the type to allowedMimeTypes. SVG can carry scripts, so it's always stored as attachment — converting icons to PNG or WebP is usually better.
Do I need sharp and the AWS SDK for CRUD only?#
No. They load lazily, only when an upload is handled.
Authentication#
Startup error about the token secret#
token.secret is missing or shorter than 32 characters. Generate one with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))".
Every request returns 401 after a password change#
Intended: tokens issued before a password change are rejected. Sign in again.
/password/forgot succeeds but no email arrives#
The endpoint answers the same whether or not the account exists, so check the server log. No transporter or sender configured means codes are never generated (a warning is logged at startup); a failing sender returns 503.
Setup#
Can I use this from CommonJS?#
The package is ESM-only. From CommonJS: const cs = await import('express-controller-sets').
Can one router serve two models?#
No — one router, one model. Two routers on one model is common and recommended: public reads, staff writes.
Is legacyMode safe to leave on?#
No. It turns off field gating, restores raw regex and removes the read cap. It exists to deploy an upgrade today and tighten afterwards, and is removed in 4.0. See Upgrading.