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;
}
}
export interface CursorPayload {
id: string;
createdAt: string;
}
export function encodeCursor(payload: CursorPayload): string {
const json = JSON.stringify(payload);
return Buffer.from(json, 'utf8').toString('base64url');
}
export function decodeCursor(token: string): CursorPayload | null {
try {
const json = Buffer.from(token, 'base64url').toString('utf8');
const parsed = JSON.parse(json);
if (typeof parsed?.id !== 'string' || typeof parsed?.createdAt !== 'string') {
return null;
}
return parsed as CursorPayload;
} catch {
return null;
}
}
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Post } from './post.entity';
import { CursorPaginationQuery, PageDto } from './pagination.dto';
import { decodeCursor, encodeCursor } from './cursor.util';
@Injectable()
export class PostsService {
constructor(
@InjectRepository(Post)
private readonly posts: Repository<Post>,
) {}
async paginate(query: CursorPaginationQuery): Promise<PageDto<Post>> {
const { limit, cursor } = query;
const qb = this.posts
.createQueryBuilder('post')
.orderBy('post.createdAt', 'DESC')
.addOrderBy('post.id', 'DESC')
.take(limit + 1);
const decoded = cursor ? decodeCursor(cursor) : null;
if (decoded) {
qb.where('(post.createdAt, post.id) < (:createdAt, :id)', {
createdAt: decoded.createdAt,
id: decoded.id,
});
}
const rows = await qb.getMany();
const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, limit) : rows;
const last = items[items.length - 1];
const nextCursor =
hasMore && last
? encodeCursor({ id: last.id, createdAt: last.createdAt.toISOString() })
: null;
return new PageDto(items, nextCursor, hasMore);
}
}
import { Controller, Get, Query } from '@nestjs/common';
import { PostsService } from './posts.service';
import { CursorPaginationQuery, PageDto } from './pagination.dto';
import { Post } from './post.entity';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Get()
list(@Query() query: CursorPaginationQuery): Promise<PageDto<Post>> {
return this.postsService.paginate(query);
}
}
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
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.