typescript 107 lines · 4 tabs

Cursor-Based Pagination for a TypeORM Repository in NestJS

Shared by codesnips Aug 2026
4 tabs
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';

export class CursorPaginationQuery {
  @IsOptional()
  @IsString()
  cursor?: string;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit = 20;
}

export class PageDto<T> {
  readonly items: T[];
  readonly nextCursor: string | null;
  readonly hasMore: boolean;

  constructor(items: T[], nextCursor: string | null, hasMore: boolean) {
    this.items = items;
    this.nextCursor = nextCursor;
    this.hasMore = hasMore;
  }
}
4 files · typescript Explain with highlit

Cursor-based pagination avoids the classic problems of OFFSET/LIMIT: as offsets grow the database still scans and discards every skipped row, and rows shifting under a client cause items to be seen twice or skipped entirely. This snippet implements keyset pagination against a TypeORM repository in NestJS, where the client carries an opaque cursor that encodes the sort position of the last row it saw, so the next page is fetched with a WHERE comparison rather than an offset.

In pagination.dto.ts, CursorPaginationQuery is the reusable request DTO. limit is coerced with @Type(() => Number) and clamped by @Min/@Max so a caller cannot ask for an unbounded page, and cursor is an optional base64 token. PageDto<T> is the generic response envelope: it carries the items, the nextCursor to request the following page, and hasMore so clients know when to stop. Keeping both shapes in one file makes the contract easy to reuse across resources.

cursor.util.ts isolates the encoding. A cursor here is just the id and createdAt of the last row, JSON-serialized and base64-encoded by encodeCursor. decodeCursor reverses it and returns null on malformed input rather than throwing, so a garbage token degrades to "start from the beginning" instead of a 500. The token is deliberately opaque — clients should treat it as a handle, which lets the server change the underlying sort keys later without breaking anyone.

posts.service.ts does the real work. paginate orders by createdAt DESC, id DESC — the id tiebreaker is essential because createdAt is not guaranteed unique, and without it a keyset comparison can drop or duplicate rows sharing a timestamp. It fetches limit + 1 rows: the extra row is the cheap way to compute hasMore without a second COUNT query. When a cursor is present it applies a compound comparison (createdAt, id) < (:createdAt, :id) via a raw WHERE so the composite key ordering is honored. The trailing sentinel row is sliced off, and nextCursor is built from the true last item.

posts.controller.ts wires it together: @Query() binds and validates the DTO through the global ValidationPipe, and the endpoint simply returns the PageDto. A trade-off worth noting is that keyset pagination supports only next/previous traversal, not jumping to an arbitrary page number, which is the right shape for infinite-scroll feeds and API cursors.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor-Based Pagination for a TypeORM Repository in NestJS — share card
Link copied