nestjs-filter
Guides

Cursor Pagination

Stable keyset (seek) pagination with FilterRunner.findPage() — opaque before/after cursors, multi-column sort with a primary-key tiebreaker.

Offset pagination (page/size) drifts under concurrent writes: insert a row near the top of the list and every subsequent page shifts by one, so a client paging forward can see a row twice or skip one entirely. It also gets slower the deeper you page — the database still scans and discards every skipped row.

Keyset (cursor) pagination fixes both. Instead of an offset, each page carries an opaque cursor describing the boundary row, and the next page is fetched with a WHERE (sort columns, pk) > (boundary values) seek. It is O(1) per page regardless of depth, and stable under inserts and deletes.

FilterRunner.findPage() implements this over the same metadata-driven dynamic path as findAndCount — no filter class required.


Quick start

import { Injectable } from '@nestjs/common';
import { FilterRunner } from '@dudousxd/nestjs-filter';
import { Post } from './post.entity.js';

@Injectable()
export class PostsService {
  constructor(private readonly runner: FilterRunner) {}

  async feed(input: Record<string, unknown>) {
    // findPage creates its own query builder from the adapter, applies
    // filters/search/includes, seeks past the cursor, and executes.
    const page = await this.runner.findPage(Post, input);
    return page;
    // {
    //   items: Post[],          // this page's rows, in the requested order
    //   nextCursor: string|null // opaque cursor for the next forward page
    //   prevCursor: string|null // opaque cursor for the previous page
    //   hasNext: boolean,
    //   hasPrev: boolean,
    // }
  }
}

A controller wiring it to query-string input:

import { Controller, Get, Query } from '@nestjs/common';

@Controller('posts')
export class PostsController {
  constructor(private readonly posts: PostsService) {}

  // GET /posts?sort=-createdAt&paginate[first]=25
  // GET /posts?sort=-createdAt&paginate[after]=<cursor>&paginate[first]=25
  @Get()
  list(@Query() query: Record<string, unknown>) {
    return this.posts.feed(query);
  }
}

The pagination input

findPage reads the paginate section of structured input:

FieldMeaning
firstPage size when paging forward. Defaults to 25, capped at maxPageSize (default 100).
afterOpaque cursor — return the page immediately after it (forward).
lastPage size when paging backward.
beforeOpaque cursor — return the page immediately before it (backward).

after and before are mutually exclusive; if both are present, after wins and paging is forward. The very first page omits both cursors:

// First page
{ "sort": "-createdAt", "paginate": { "first": 25 } }

// Next page — feed back the previous response's nextCursor
{ "sort": "-createdAt", "paginate": { "after": "eyJ...", "first": 25 } }

// Previous page — feed back a prevCursor
{ "sort": "-createdAt", "paginate": { "before": "eyJ...", "last": 25 } }

Keep the same sort across every page of a walk. The cursor encodes the values of the sort columns for the boundary row; changing the sort between requests makes the boundary meaningless and the seek incorrect.


How the keyset is built

The keyset is the request's effective sort (or the configured defaultSort when the client sends none), with the entity's primary key appended as a tiebreaker — inheriting the direction of the last sort column so the overall ordering stays strictly monotonic.

That tiebreaker is what makes the seek correct when the sort columns are not unique. Sorting only by createdAt, two rows sharing a timestamp are ambiguous; (createdAt, id) is always a total order, so no row is ever skipped or repeated at a page boundary.

// Client sends: sort = "-createdAt,title"
// Entity primary key: "id"
//
// Effective keyset (pk appended, inheriting the last column's direction):
//   [ { field: 'createdAt', direction: 'desc' },
//     { field: 'title',     direction: 'asc'  },
//     { field: 'id',        direction: 'asc'  } ]  // tiebreaker

Each column's direction is honored independently: an asc column seeks forward with >, a desc column with <. For backward paging (before), the runner reverses every column's direction internally to walk the other way, then re-reverses the fetched rows so items always comes back in the requested order.

Multi-column sort composes naturally — a mixed -createdAt,title keyset produces a correct lexicographic seek across all three columns.


Cursors are opaque

A cursor is a base64url-encoded JSON snapshot of the boundary row's keyset values. Treat it as opaque: only findPage produces and consumes it. You never build one by hand — echo the nextCursor/prevCursor from the previous response back into the next request.

Date values round-trip correctly (they are tagged on encode so they decode back to Date instances, not strings — plain JSON would break date comparisons). A malformed or wrong-length cursor is ignored, not fatal: findPage simply returns the first page rather than throwing.

If you need the primitives directly (custom transport, testing), they are exported:

import {
  encodeCursor,
  decodeCursor,
  buildKeyset,
  extractCursorValues,
} from '@dudousxd/nestjs-filter';
import type { CursorValues } from '@dudousxd/nestjs-filter';

const keyset = buildKeyset(
  [{ field: 'createdAt', direction: 'desc' }],
  'id', // primary key tiebreaker
);
const values = extractCursorValues(row, keyset); // ordered keyset values
const cursor = encodeCursor(values);             // opaque string
const decoded: CursorValues | null = decodeCursor(cursor); // null if malformed

Adapter requirements

findPage needs an adapter that implements getResult, getPrimaryKey, applyKeysetPagination, and applyKeysetOrderAndLimit. The official TypeORM and MikroORM adapters implement all four. If any is missing, findPage throws a clear error at call time rather than silently degrading:

findPage requires an adapter implementing getResult, getPrimaryKey,
applyKeysetPagination and applyKeysetOrderAndLimit.

findPage is additive — it does not change apply, applyDynamic, or findAndCount. Use offset pagination (findAndCount) when you need a total count and random page access; use findPage for infinite-scroll / "load more" feeds where stability and depth-independent speed matter.


Full example: an infinite-scroll endpoint

import { Injectable } from '@nestjs/common';
import { FilterRunner } from '@dudousxd/nestjs-filter';
import type { CursorPage } from '@dudousxd/nestjs-filter';
import { Post } from './post.entity.js';

@Injectable()
export class FeedService {
  constructor(private readonly runner: FilterRunner) {}

  async page(query: Record<string, unknown>): Promise<CursorPage<Post>> {
    // Client-driven filter + search + include still apply on the seek path;
    // findPage owns ordering and slicing.
    return this.runner.findPage(Post, {
      filter: query.filter,
      search: query.search,
      include: 'author',
      sort: query.sort ?? '-createdAt',
      paginate: query.paginate,
    });
  }
}
import { Controller, Get, Query } from '@nestjs/common';
import { FeedService } from './feed.service.js';

@Controller('feed')
export class FeedController {
  constructor(private readonly feed: FeedService) {}

  // GET /feed?search=nestjs&paginate[after]=<cursor>&paginate[first]=20
  @Get()
  async list(@Query() query: Record<string, unknown>) {
    const page = await this.feed.page(query);
    return {
      data: page.items,
      pageInfo: {
        endCursor: page.nextCursor,
        startCursor: page.prevCursor,
        hasNextPage: page.hasNext,
        hasPreviousPage: page.hasPrev,
      },
    };
  }
}

On this page