npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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.

npm version npm downloads Bundle Size License: MIT JavaScript TypeScript Mongoose


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, $eq for 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, _regex via 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 + ESMrequire() for Node.js, import for 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-kit
yarn add filter-kit

Mongoose >=6.0.0 required as a peer dependency:

npm install mongoose

Quick 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.role

Operator 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-01

Soft 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 UI

Full 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=desc

NestJS

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=desc

Security

  • Reserved params (page, limit, sort, order, search, from, to, populate, select) are never treated as field filters.
  • Unknown keys are silently dropped — not in allowedFilters and not in model.schema.paths = ignored.
  • customFilter and softDelete are 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,
});

License

MIT © parthpatel2597