filter-kit
v2.0.4
Published
Mongoose query builder for JavaScript and TypeScript — smart search (string + number), deep populate (4 levels), multi-sort, operator filters (_gte _lte _ne _in _regex), date ranges, soft-delete, pagination. One function for full REST API filtering from U
Maintainers
Keywords
Readme
filter-kit
The last Mongoose query builder you'll ever need.
One function. Full REST API filtering — search, paginate, sort, populate, date ranges, operator filters, soft-delete — all from URL query params.
Works with plain JavaScript and TypeScript. No compilation step needed.
Why filter-kit?
- Zero boilerplate — one call replaces 50+ lines of hand-written filter logic
- URL-driven — every option is controllable via
req.query - Smart search — regex for strings,
$eqfor numbers, exact match mode - Deep populate — dot-notation nesting up to 4 levels (
user.role.department.company) - Operator filters —
_gte,_lte,_gt,_lt,_ne,_in,_nin,_exists,_regexvia URL - Multi-sort —
?sort=createdAt,name&order=desc,asc - Soft-delete guard — auto-exclude deleted documents with one option
- Security gate — unknown filter keys are silently dropped
- Works in JavaScript & TypeScript — ships pre-compiled, no build step needed
- Dual CJS + ESM —
require()for Node.js,importfor bundlers / ESM projects - Full TypeScript types — generic types, zero
@types/install needed
Demo
Example — what one URL does:
GET /users?search=john&role=admin&age_gte=18&sort=createdAt,name&order=desc,asc&page=2&limit=10&populate=user.role&from=2024-01-01{
"data": [
{ "_id": "...", "name": "John Admin", "role": { "name": "Administrator" }, "age": 28 }
],
"pagination": {
"total": 47,
"page": 2,
"limit": 10,
"pages": 5,
"hasNext": true,
"hasPrev": true
}
}Install
npm install filter-kityarn add filter-kitMongoose >=6.0.0 required as a peer dependency:
npm install mongooseQuick Start
JavaScript — CommonJS (require)
const { queryBuilder } = require('filter-kit');
const mongoose = require('mongoose');
const UserSchema = new mongoose.Schema({ name: String, email: String, role: String });
const User = mongoose.model('User', UserSchema);
app.get('/users', async (req, res) => {
const result = await queryBuilder({
model: User,
query: req.query, // e.g. ?search=john&role=admin&page=1&limit=10
searchFields: ['name', 'email'],
allowedFilters: ['role', 'status'],
defaultSort: 'createdAt',
defaultOrder: 'desc',
populate: 'role',
dateField: 'createdAt',
softDelete: { field: 'isDeleted' },
customFilter: { isActive: true },
});
res.json(result);
// { data: [...], pagination: { total, page, limit, pages, hasNext, hasPrev } }
});JavaScript — ESM (import)
import { queryBuilder } from 'filter-kit';
app.get('/users', async (req, res) => {
const result = await queryBuilder({
model: User,
query: req.query,
searchFields: ['name', 'email'],
allowedFilters: ['role', 'status'],
defaultSort: 'createdAt',
defaultOrder: 'desc',
populate: 'role',
dateField: 'createdAt',
softDelete: { field: 'isDeleted' },
});
res.json(result);
});TypeScript
import { queryBuilder, QueryBuilderResult } from 'filter-kit';
import { UserDocument } from './user.model';
app.get('/users', async (req, res) => {
const result: QueryBuilderResult<UserDocument> = await queryBuilder<UserDocument>({
model: User,
query: req.query,
searchFields: ['name', 'email'],
allowedFilters: ['role', 'status'],
defaultSort: 'createdAt',
defaultOrder: 'desc',
populate: 'role',
dateField: 'createdAt',
softDelete: { field: 'isDeleted' },
});
res.json(result);
});Features
Smart Search — strings, numbers, exact
searchFields: [
'name', // string → case-insensitive regex
'email', // string → case-insensitive regex
{ field: 'age', type: 'number' }, // number → $eq (skips non-numeric search)
{ field: 'code', type: 'exact' }, // string → exact match, no regex
]?search=john → { $or: [ { name: /john/i }, { email: /john/i } ] }
?search=42 → { $or: [ { name: /42/i }, { age: { $eq: 42 } } ] }AND mode — every space-separated word must match at least one field:
searchMode: 'and'
// ?search=john admin
// → { $and: [
// { $or: [{ name: /john/i }, { email: /john/i }] },
// { $or: [{ name: /admin/i }, { email: /admin/i }] }
// ]}Deep Populate — up to 4 levels
Use dot-notation in the ?populate= query param or the populate option:
?populate=user.role.department.company// Produces:
{
path: 'user',
populate: {
path: 'role',
populate: {
path: 'department',
populate: { path: 'company' }
}
}
}Mix flat and deep in one request:
?populate=company,user.roleOperator Filters — via URL
Append a suffix to any allowed field name in the URL:
| Suffix | MongoDB Op | Example URL | Result |
|--------|-----------|-------------|--------|
| _gte | $gte | ?age_gte=18 | { age: { $gte: 18 } } |
| _lte | $lte | ?age_lte=65 | { age: { $lte: 65 } } |
| _gt | $gt | ?price_gt=100 | { price: { $gt: 100 } } |
| _lt | $lt | ?price_lt=500 | { price: { $lt: 500 } } |
| _ne | $ne | ?status_ne=deleted | { status: { $ne: 'deleted' } } |
| _in | $in | ?role_in=admin,editor | { role: { $in: ['admin','editor'] } } |
| _nin | $nin | ?role_nin=banned,guest | { role: { $nin: ['banned','guest'] } } |
| _exists | $exists | ?avatar_exists=false | { avatar: { $exists: false } } |
| _regex | $regex | ?name_regex=^john | { name: { $regex: /^john/i } } |
Multiple operators on the same field are merged automatically:
?age_gte=18&age_lte=65 → { age: { $gte: 18, $lte: 65 } }Multi-Sort
?sort=createdAt,name&order=desc,asc
→ { createdAt: -1, name: 1 }Multiple Date Ranges
Filter different date fields with custom param names:
dateFields: [
{ field: 'createdAt', fromParam: 'createdFrom', toParam: 'createdTo' },
{ field: 'updatedAt', fromParam: 'updatedFrom', toParam: 'updatedTo' },
]
// ?createdFrom=2024-01-01&updatedFrom=2024-06-01Soft Delete Guard
softDelete: { field: 'isDeleted' } // excludes { isDeleted: true }
softDelete: { field: 'deletedAt', value: null } // excludes { deletedAt: null }Auto-merges into every query via $and. Cannot be bypassed by URL params.
Skip Count for Performance
withCount: false // skips countDocuments — faster for infinite scroll / cursor UIFull Options Reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| model | Model<T> | required | Mongoose model to query. |
| query | Record<string, any> | required | Parsed query string (req.query). |
| searchFields | SearchField[] | undefined | Fields for ?search=. Strings = regex, { type: 'number' } = $eq, { type: 'exact' } = literal. |
| searchMode | 'or' \| 'and' | 'or' | 'or' — any field matches. 'and' — every space-split term must match. |
| allowedFilters | string[] | undefined | Extra keys allowed as dynamic filters beyond schema paths. |
| defaultSort | string | 'createdAt' | Sort field when ?sort= absent. |
| defaultOrder | 'asc' \| 'desc' | 'desc' | Sort direction when ?order= absent. |
| defaultLimit | number | 10 | Page size when ?limit= absent. |
| maxLimit | number | 100 | Hard cap on page size. |
| populate | string \| string[] \| PopulateOptions \| PopulateOptions[] | undefined | Populate when ?populate= absent. |
| select | ProjectionType<T> \| string | undefined | Field projection when ?select= absent. |
| dateField | string | undefined | Single date field for ?from= / ?to=. |
| dateFields | DateFieldConfig[] | undefined | Multiple date fields with custom param names. |
| lean | boolean | false | Return plain JS objects (faster reads). |
| customFilter | Record<string, any> | undefined | Always-on filter, merged via $and. Cannot be bypassed by URL. |
| softDelete | SoftDeleteConfig | undefined | Auto-exclude soft-deleted docs ({ field, value? }). |
| withCount | boolean | true | Set false to skip countDocuments for perf. |
Query Parameters Reference
| Param | Example | Description |
|-------|---------|-------------|
| search | ?search=john | Case-insensitive search across searchFields. |
| page | ?page=2 | Page number (1-based). |
| limit | ?limit=25 | Docs per page (clamped to maxLimit). |
| sort | ?sort=name,age | Comma-separated sort fields. |
| order | ?order=asc,desc | Per-field order, matches sort positions. |
| from | ?from=2024-01-01 | Date range start ($gte) on dateField. |
| to | ?to=2024-12-31 | Date range end ($lte, end of day). |
| populate | ?populate=user.role,company | Comma-separated paths. Dot = nested populate. |
| select | ?select=name,email | Comma-separated fields to include. |
| <field> | ?status=active | Dynamic filter (schema path or allowedFilters). |
| <field>_gte | ?age_gte=18 | Greater-than-or-equal operator filter. |
| <field>_lte | ?age_lte=65 | Less-than-or-equal operator filter. |
| <field>_gt | ?price_gt=99 | Greater-than operator filter. |
| <field>_lt | ?price_lt=500 | Less-than operator filter. |
| <field>_ne | ?status_ne=deleted | Not-equal operator filter. |
| <field>_in | ?role_in=admin,mod | $in array filter. |
| <field>_nin | ?role_nin=banned | $nin array filter. |
| <field>_exists | ?photo_exists=false | Field existence filter. |
| <field>_regex | ?name_regex=^A | Regex filter (case-insensitive). |
Examples
Express (JavaScript)
const express = require('express');
const { queryBuilder } = require('filter-kit');
const Product = require('./models/Product');
const app = express();
app.get('/products', async (req, res, next) => {
try {
const result = await queryBuilder({
model: Product,
query: req.query,
searchFields: [
'name',
'description',
{ field: 'price', type: 'number' }, // number field → $eq match
],
allowedFilters: ['category', 'brand', 'inStock'],
defaultSort: 'createdAt',
defaultOrder: 'desc',
defaultLimit: 20,
maxLimit: 200,
dateField: 'createdAt',
populate: [{ path: 'category', select: 'name slug' }],
customFilter: { isActive: true },
softDelete: { field: 'isDeleted' },
lean: true,
});
res.json(result);
} catch (err) {
next(err);
}
});Client calls:
GET /products?search=shoe&price_gte=50&price_lte=200&brand=nike,adidas&sort=price,name&order=asc,asc&page=1&limit=20
GET /products?category=electronics&inStock=true&createdAt_exists=true
GET /products?name_regex=^air&sort=createdAt&order=descNestJS
import { Controller, Get, Query } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { queryBuilder, QueryBuilderResult } from 'filter-kit';
import { User, UserDocument } from './user.schema';
@Controller('users')
export class UsersController {
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
) {}
@Get()
async findAll(@Query() query: Record<string, any>): Promise<QueryBuilderResult<UserDocument>> {
return queryBuilder({
model: this.userModel,
query,
searchFields: ['name', 'email'],
searchMode: 'and',
allowedFilters: ['role', 'status', 'department'],
populate: 'role.permissions', // level 2 deep
dateField: 'createdAt',
softDelete: { field: 'isDeleted' },
});
}
}Advanced — Orders with multiple date ranges
const result = await queryBuilder({
model: Order,
query: req.query,
searchFields: ['orderNumber', 'customerName'],
allowedFilters: ['status', 'paymentMethod'],
dateFields: [
{ field: 'placedAt', fromParam: 'placedFrom', toParam: 'placedTo' },
{ field: 'shippedAt', fromParam: 'shippedFrom', toParam: 'shippedTo' },
],
populate: [
{ path: 'customer', select: 'name email phone' },
{ path: 'items.product', select: 'name sku price' },
],
softDelete: { field: 'isArchived', value: true },
withCount: false, // infinite scroll — skip the count query
lean: true,
});Client call:
GET /orders?placedFrom=2024-01-01&placedTo=2024-03-31&status=pending,processing&paymentMethod_ne=cash&sort=placedAt&order=descSecurity
- Reserved params (
page,limit,sort,order,search,from,to,populate,select) are never treated as field filters. - Unknown keys are silently dropped — not in
allowedFiltersand not inmodel.schema.paths= ignored. customFilterandsoftDeleteare always applied server-side — URL params cannot override them.- Operator suffixes (
_gte, etc.) are only applied when the base field passes the security gate.
TypeScript
Full types ship with the package — no @types/ install needed.
import {
queryBuilder,
QueryBuilderOptions,
QueryBuilderResult,
PaginationMeta,
SearchField,
SearchFieldConfig,
SearchFieldType,
DateFieldConfig,
SoftDeleteConfig,
} from 'filter-kit';
// Generic — result.data is UserDocument[]
const result = await queryBuilder<UserDocument>({
model: User,
query: req.query,
});