Lorsqu'une API expose une liste de ressources, il est tentant de tout renvoyer en une seule requête. Cette approche fonctionne au début, mais elle devient rapidement problématique : requêtes lentes, bande passante saturée, mémoire serveur sous tension et expérience utilisateur dégradée. Dans ce tutoriel, nous allons construire un système de pagination et de filtrage réutilisable pour NestJS, en s'appuyant sur TypeORM, les DTO et la validation. Nous reprendrons les conventions déjà en place dans une API existante (réponses normalisées via ApiResponseDto, IDs UUID, décorateurs @Roles et @Public).
Pourquoi paginer ?
La pagination répond à plusieurs enjeux concrets :
- Performance : une requête
SELECT * FROM bookingssur 100 000 lignes mobilise inutilement la base et le réseau. - UX : l'utilisateur ne lit jamais 10 000 résultats. Mieux vaut afficher 20 entrées et permettre la navigation.
- Mémoire : Node.js charge tout le résultat en RAM avant de le sérialiser en JSON. Sans limite, un pic de trafic peut faire planter l'instance.
- Coût : moins de données transférées = factures cloud plus légères.
L'objectif est donc de proposer trois mécanismes complémentaires : pagination (page/limit), filtrage (par statut, dates, etc.) et tri dynamique.

Prérequis
- NestJS 10+ avec un module TypeORM configuré
class-validatoretclass-transformerinstallés- Un
ValidationPipeglobal avectransform: trueactivé dansmain.ts
1. Un DTO de pagination réutilisable
La première étape consiste à créer un DTO générique qui sera étendu par tous les endpoints listant des ressources. Il porte les paramètres communs : page, limit, sortBy, sortOrder et un éventuel search.
// src/common/dto/pagination-query.dto.ts
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, IsIn, Min, Max, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class PaginationQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100) // Limite maximale pour éviter les abus
@IsOptional()
limit: number = 20;
@ApiPropertyOptional({ description: 'Champ utilisé pour le tri' })
@IsString()
@IsOptional()
@MaxLength(50)
sortBy?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsIn(['ASC', 'DESC'])
@IsOptional()
sortOrder: 'ASC' | 'DESC' = 'DESC';
@ApiPropertyOptional({ description: 'Recherche textuelle' })
@IsString()
@IsOptional()
@MaxLength(100)
search?: string;
}
Quelques points clés :
@Type(() => Number)est indispensable car les query params arrivent enstring. Sans cette transformation,@IsInt()échouerait systématiquement.@Max(100)protège la base d'un client qui demanderait?limit=999999.- Les valeurs par défaut sont fixées directement dans la classe : si le client ne les fournit pas, NestJS les conserve.
2. Un format de réponse paginée standardisé
Pour rester cohérent avec le pattern ApiResponseDto déjà utilisé par les contrôleurs (ServicesController, BookingController), nous créons une enveloppe dédiée à la pagination.
// src/common/dto/paginated-result.dto.ts
export class PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
lastPage: number;
hasNext: boolean;
hasPrev: boolean;
};
constructor(data: T[], total: number, page: number, limit: number) {
this.data = data;
const lastPage = Math.max(1, Math.ceil(total / limit));
this.meta = {
total,
page,
limit,
lastPage,
hasNext: page < lastPage,
hasPrev: page > 1,
};
}
}
Ce format expose tout ce dont un front a besoin pour afficher une pagination correcte : nombre total d'éléments, page courante, dernière page, et booléens de navigation.
3. Étendre le DTO pour des filtres spécifiques
Reprenons l'exemple des bookings. Le service BookingService.findAll() accepte déjà status, startDate et endDate. Combinons ces filtres avec la pagination en étendant le DTO de base.
// src/modules/booking/dto/find-bookings.dto.ts
import { IsEnum, IsOptional, IsDateString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { BookingStatus } from '../../../database/entities/booking.entity';
export class FindBookingsDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: BookingStatus })
@IsEnum(BookingStatus)
@IsOptional()
status?: BookingStatus;
@ApiPropertyOptional({ description: 'Date de début (YYYY-MM-DD)' })
@IsDateString()
@IsOptional()
startDate?: string;
@ApiPropertyOptional({ description: 'Date de fin (YYYY-MM-DD)' })
@IsDateString()
@IsOptional()
endDate?: string;
}
L'héritage permet de mutualiser page, limit, sortBy et sortOrder tout en ajoutant les filtres spécifiques aux bookings.
4. Implémentation côté service avec TypeORM
Le QueryBuilder de TypeORM est idéal ici : il combine élégamment where, orderBy, skip et take. On ajoute aussi un garde-fou sur les colonnes triables pour éviter une injection SQL via sortBy.
// Extrait de booking.service.ts
import { PaginatedResult } from '../../common/dto/paginated-result.dto';
import { FindBookingsDto } from './dto/find-bookings.dto';
private readonly ALLOWED_SORT_FIELDS = [
'createdAt',
'bookingDate',
'status',
'amountPaid',
];
async findAllPaginated(query: FindBookingsDto): Promise<PaginatedResult<Booking>> {
const { page, limit, sortBy, sortOrder, search, status, startDate, endDate } = query;
const qb = this.bookingRepository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.service', 'service')
.leftJoinAndSelect('booking.user', 'user');
// Filtrage par statut
if (status) {
qb.andWhere('booking.status = :status', { status });
}
// Filtrage par plage de dates
if (startDate) {
qb.andWhere('booking.bookingDate >= :startDate', { startDate });
}
if (endDate) {
qb.andWhere('booking.bookingDate <= :endDate', { endDate });
}
// Recherche textuelle (PostgreSQL : ILIKE pour case-insensitive)
if (search) {
qb.andWhere(
'(booking.guestName ILIKE :search OR booking.guestEmail ILIKE :search OR booking.question ILIKE :search)',
{ search: `%${search}%` },
);
}
// Tri sécurisé : on n'accepte que des colonnes whitelistées
const safeSortBy = this.ALLOWED_SORT_FIELDS.includes(sortBy ?? '')
? sortBy!
: 'createdAt';
qb.orderBy(`booking.${safeSortBy}`, sortOrder);
// Pagination
qb.skip((page - 1) * limit).take(limit);
const [data, total] = await qb.getManyAndCount();
return new PaginatedResult(data, total, page, limit);
}
Pourquoi getManyAndCount() ? Cette méthode exécute deux requêtes en parallèle : une pour récupérer la page et une pour le total. C'est exactement ce qu'il faut pour calculer lastPage sans charger toute la table.
Astuce sécurité : ne JAMAIS injecter directement
sortBydansorderBy()sans whitelist. Un attaquant pourrait passer?sortBy=(SELECT...)et provoquer un comportement inattendu. La listeALLOWED_SORT_FIELDSferme la porte à ce type d'abus.
5. Mise à jour du contrôleur
Le contrôleur reste très simple : on remplace les multiples @Query() individuels par un unique DTO validé automatiquement.
// Extrait de booking.controller.ts
@Get()
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get paginated bookings' })
@ApiResponse({ status: 200, description: 'Paginated list of bookings' })
async findAll(
@Query() query: FindBookingsDto,
): Promise<ApiResponseDto<PaginatedResult<Booking>>> {
const result = await this.bookingService.findAllPaginated(query);
return ApiResponseDto.success(result, 'Bookings retrieved successfully');
}
Un appel typique côté client ressemblera à :
curl "https://api.exemple.com/bookings?page=2&limit=20&status=confirmed&sortBy=bookingDate&sortOrder=ASC&search=dupont"
6. Recherche textuelle : LIKE vs ILIKE
Sur PostgreSQL, ILIKE permet une recherche insensible à la casse. Sur MySQL, on utilisera LOWER(colonne) LIKE LOWER(:search). Pour des recherches plus avancées (multi-mots, pertinence), pensez à to_tsvector/to_tsquery sur PostgreSQL ou à un moteur dédié comme Meilisearch ou Elasticsearch.
Attention aux caractères spéciaux : % et _ sont interprétés par LIKE. Si la recherche peut contenir ces caractères, échappez-les avant l'injection dans la requête.
7. Bonnes pratiques à retenir
- Toujours imposer une limite max (
@Max(100)) pour empêcher les requêtes destructrices. - Valeurs par défaut sensées :
page=1,limit=20, tri parcreatedAt DESC. - Whitelister les champs triables pour bloquer l'injection SQL via
sortBy. - Indexer les colonnes filtrées (
status,booking_date,created_at) pour des performances stables sur de gros volumes. - Privilégier
getManyAndCount()plutôt que deux appels séparés. - Documenter avec Swagger via
@ApiPropertyOptional: la documentation reste synchronisée avec le code. - Pour de très gros datasets, envisagez la pagination par curseur (
cursor-based) plus performante queOFFSETsur des tables de plusieurs millions de lignes.
Conclusion
Nous avons construit un système de pagination et de filtrage générique, validé et sécurisé, parfaitement intégré au pattern ApiResponseDto de l'API. Le DTO PaginationQueryDto est réutilisable sur n'importe quelle ressource — services, utilisateurs, créneaux horaires — et son extension via héritage permet d'ajouter des filtres métier sans dupliquer la logique de base.
Pour aller plus loin, vous pouvez explorer :
- La pagination par curseur avec des bibliothèques comme
nestjs-paginateoutypeorm-cursor-pagination - La mise en cache des résultats paginés via
CacheInterceptorde NestJS - La recherche full-text avec PostgreSQL (
tsvector) ou un moteur dédié - L'export CSV streamé pour les cas où l'utilisateur a vraiment besoin de toutes les données
Avec ces fondations, votre API restera rapide et prévisible, quel que soit le volume de données qu'elle manipule.

Commentaires