Express.js and Mongoose: Handling Duplicate Query Parameters in Pagination
0 reputation · 28 Jan 2024, 09:27 UTC
0 reputation · 28 Jan 2024, 09:27 UTC
Express 4.x uses the qs library by default to parse req.query, which supports extended syntax for nested objects and arrays. When building paginated endpoints, pagination parameters like limit and page are typically extracted from this object and passed to Mongoose limit() and skip() methods.
A specific behavior occurs when a client sends duplicate keys in the query string (e.g., ?limit=10&limit=20). In this scenario, the default Express parser transforms req.query.limit into an array of strings ['10', '20'] rather than a single primitive value.
Because Mongoose expects a number for the limit() method, passing this parsed array directly can lead to runtime type errors or inconsistent results depending on the specific ORM version and MongoDB driver.
limit() method?31275 reputation · 28 Jan 2024, 12:48 UTC
limit() and skip() are documented to take a number, and Mongoose does not cast the argument for you. The value is stored as-is in the query's options (inspectable via the documented query.options property) and handed to the MongoDB driver. With ?limit=10&limit=20 under Express 4's default qs-based parser, req.query.limit is ['10', '20'], so that array is what reaches the driver. Whether you then see a driver-side type error or a silently unapplied, unbounded limit depends on your Mongoose and mongodb driver versions — treat the exact failure mode as version-dependent and verify against your installed versions. One pitfall holds regardless of version: limit(0) means 'no limit' in MongoDB, so sloppy coercion that yields 0 produces an unbounded query.
There is no documented qs option to force single values for specific keys. The documented extension point is app.set('query parser', fn), which accepts a custom function in Express 4 and 5; wrap qs.parse there and normalize the keys you care about. Switching to app.set('query parser', 'simple') does not help: Node's simple parser also returns an array for repeated keys — it only disables nested syntax like a[b]=c.
The version-agnostic remedy is a small coercion helper applied to every paginated parameter, not just limit:
function intParam(value, fallback, max) {
const raw = Array.isArray(value) ? value[value.length - 1] : value;
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? Math.min(n, max) : fallback;
}
const limit = intParam(req.query.limit, 20, 100);
const page = intParam(req.query.page, 1, 10000);
const skip = (page - 1) * limit;
const items = await Item.find(filter).skip(skip).limit(limit);
Three details in that helper are deliberate:
Number() alone is unsafe. Number(['10','20']) is NaN, but Number('') and Number(null) are 0 — and limit(0) means unbounded. Hence the Number.isInteger plus > 0 check.Math.min clamp enforced server-side; that clamp, not the first-vs-last choice, is the real defense.req.query into Mongoose filters while fixing this. The extended parser's nested syntax enables operator injection such as ?price[$gt]=0; whitelist the pagination keys explicitly.curl 'http://localhost:3000/items?limit=10&limit=20' with a temporary console.log(typeof req.query.limit, req.query.limit) in the handler.Item.find(filter).limit(x).options to see the exact limit and skip values Mongoose stored.npm ls express mongoose mongodb and skim those changelogs for limit/skip validation changes before trusting any specific error message.This assumes Express 4.x with the default extended (qs) parser. Express 5 changed the default query parser to simple, so the default req.query shape differs between majors — confirm yours before relying on extended semantics. Mongoose has also adjusted limit/skip validation across major versions, which is why the exact failure mode above is marked version-dependent rather than asserted.
One detail would change which change is primary: your Express major version. On 4.x, a custom parser function preserves the extended semantics you already have; on 5.x, you would be opting back into qs deliberately. The boundary helper is the right fix either way.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.