Paginação e Filtros Avançados em APIs REST
Offset vs Cursor pagination: quando usar cada um, problema do Offset em feeds com inserções concorrentes, Cursor pagination com keyset (createdAt + id), filtros dinâmicos com Query Builder TypeORM e validação dos query params com Zod.
repository.find() sem limite é uma bomba-relógio: funciona bem com 100 registros, começa a travar com 10.000, e derruba o servidor com 1 milhão. Paginação não é otimização prematura — é a diferença entre uma API que escala e uma que precisa ser refatorada quando o produto cresce.
Existem duas abordagens principais: Offset/Limit (a clássica por página/número) e Cursor Pagination (a correta para feeds em tempo real). Cada uma tem casos de uso específicos e trade-offs importantes.
Offset Pagination: Para Painéis e CRMs
Funciona com LIMIT N OFFSET M. O usuário navega por páginas numeradas. Simples de implementar, mas tem um problema crítico de performance: com OFFSET 10000, o banco precisa percorrer (e descartar) os 10.000 primeiros registros para retornar os 20 seguintes. Com uma tabela de milhões de registros, isso se torna uma query de segundo(s). Use Offset Pagination apenas para paineis admin e CRMs onde o volume é controlado. Para feeds, notificações e listagens com inserções concorrentes, prefira Cursor Pagination.
import { z } from 'zod';
export const ListUsersQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
role: z.enum(['USER', 'MANAGER', 'ADMIN']).optional(),
emailVerified: z.coerce.boolean().optional(),
sortBy: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
sortOrder: z.enum(['ASC', 'DESC']).default('DESC'),
});
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;import { injectable, inject } from 'tsyringe';
import { ListUsersQuerySchema, type ListUsersQuery } from '../dtos/ListUsersQueryDTO';
import type { IUsersRepository } from '../repositories/IUsersRepository';
@injectable()
export class ListUsersUseCase {
constructor(
@inject('UsersRepository')
private usersRepo: IUsersRepository
) {}
async execute(rawQuery: unknown) {
// Validação e normalização dos query params
const query = ListUsersQuerySchema.parse(rawQuery);
const { data, total } = await this.usersRepo.findMany(query);
return {
data: data.map(toUserResponseDTO),
meta: {
total,
page: query.page,
pageSize: query.pageSize,
totalPages: Math.ceil(total / query.pageSize),
hasNextPage: query.page * query.pageSize < total,
hasPrevPage: query.page > 1,
},
};
}
}async findMany(params: ListUsersQuery): Promise<{ data: Array<User>; total: number }> {
const { page, pageSize, search, role, emailVerified, sortBy, sortOrder } = params;
const qb = this.repo.createQueryBuilder('user')
.where('user.deletedAt IS NULL'); // Exclui soft deleted
// Filtros condicionais — adicionados apenas quando presentes
if (search) {
qb.andWhere('(user.name ILIKE :search OR user.email ILIKE :search)', {
search: `%${search}%`,
});
}
if (role) {
qb.andWhere('user.role = :role', { role });
}
if (emailVerified !== undefined) {
qb.andWhere('user.emailVerified = :emailVerified', { emailVerified });
}
// Ordenação e paginação
const [data, total] = await qb
.orderBy(`user.${sortBy}`, sortOrder)
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount(); // Uma única query com COUNT(*) OVER()
return { data, total };
}Cursor Pagination (Keyset): Para Feeds em Tempo Real
O problema do Offset em feeds: enquanto você está na página 2, novos posts são inseridos. O post que estava no final da página 1 agora é o primeiro da página 2 — você o vê duas vezes. O Cursor Pagination resolve isso usando o último item visto como ponto de referência:
// O cursor é composto por dois campos para garantir ordenação estável:
// createdAt (pode ter empates) + id (UUID único — desempata)
interface CursorPaginationParams {
cursor?: { createdAt: string; id: string }; // ISO string + UUID
limit: number;
}
async function findPostsWithCursor(
params: CursorPaginationParams
): Promise<{ data: Array<Post>; nextCursor: string | null }> {
const qb = postRepo.createQueryBuilder('post')
.where('post.deletedAt IS NULL')
.orderBy('post.createdAt', 'DESC')
.addOrderBy('post.id', 'DESC'); // Desempate por id
if (params.cursor) {
// Busca itens ANTERIORES ao cursor (menores em createdAt, ou mesmo createdAt mas menor id)
// Esta é a query de Keyset Pagination
qb.andWhere(
'(post.createdAt < :cursorDate OR (post.createdAt = :cursorDate AND post.id < :cursorId))',
{ cursorDate: params.cursor.createdAt, cursorId: params.cursor.id }
);
}
// Pega limit + 1 para saber se existe próxima página
const items = await qb.take(params.limit + 1).getMany();
const hasNextPage = items.length > params.limit;
const data = hasNextPage ? items.slice(0, params.limit) : items;
// Cursor para a próxima página = último item retornado
const lastItem = data[data.length - 1];
const nextCursor = hasNextPage && lastItem
? Buffer.from(JSON.stringify({
createdAt: lastItem.createdAt.toISOString(),
id: lastItem.id,
})).toString('base64')
: null;
return { data, nextCursor };
}
// Resposta da API:
// GET /posts → { data: [...], nextCursor: 'eyJjcmVhdGVkQXQi...' }
// GET /posts?cursor=eyJjcmVhdGVkQXQi... → próxima páginaQuando usar cada um: Offset para painéis administrativos, CRMs, relatórios — onde o usuário precisa pular para a página N e a consistência entre páginas é menos crítica. Cursor para feeds infinitos, timelines, chats — onde inserções concorrentes são frequentes e duplicatas/gaps são inaceitáveis. O Cursor também é mais performático em tabelas grandes (INDEX RANGE SCAN vs OFFSET que descarta N linhas).
Conclusão
Toda listagem de API precisa de paginação desde o primeiro dia. Offset é mais simples e suficiente para a maioria dos casos. Cursor é obrigatório para feeds e qualquer contexto com inserções concorrentes. A validação com Zod nos query params garante que page=abc não chegue como NaN no banco. E o getManyAndCount() do TypeORM retorna dados + total em uma única query — sem SELECT COUNT(*) FROM ... separado.