Voltar para Artigos
Back-end8 min de leitura

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.

12 de agosto de 2026

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.

Query params com validação Zod
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>;
ListUsersUseCase.ts
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,
      },
    };
  }
}
TypeORMUsersRepository.findMany() com Query Builder
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:

Cursor Pagination com (createdAt, id) como cursor
// 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ágina

Quando 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.