Coming from nestjs-paginate
The query-string contract here is deliberately borrowed from nestjs-paginate, so a front end written against a NestJS API mostly keeps working when the backend becomes .NET. "Mostly" is what this page is about: what maps one to one, what is shaped differently, and what is not here at all.
INFO
Written against nestjs-paginate's documented contract. It is a separate project on its own release cycle — check its current README before relying on a row below.
The query string
| Parameter | Here | Notes |
|---|---|---|
page | same | 1-based in both |
limit | same | but see limits |
sortBy=col:DESC | same | repeat the key for secondary sorts, same as there |
search | same | |
searchBy | same | repeat the key; can be switched off per resource with IgnoreSearchByInQueryParam() |
filter.<col>=$op:value | same | |
select=id,name | absent | see columns are not client-selectable |
withDeleted | absent | soft delete is your IQueryable's business, not the paginator's |
cursor | absent | offset paging only |
filter= expression | absent | boolean logic uses $and / $or prefixes instead |
Anything not on this list is ignored rather than rejected — including parameters that were meaningful to nestjs-paginate, so a client that still sends select or withDeleted pages normally instead of failing. That is worth knowing during a gradual migration, because it also means those parameters silently stop having an effect.
Operators
Every operator token carries over, with the same spelling:
$eq $in $null $sw $ilike $contains $lt $lte $gt $gte $btw, plus the $not modifier.
Two differences in how they combine:
- Repeating
filter.<field>meansANDin both.?filter.rank=$gte:20&filter.rank=$lte:50is a range either way. ORdoes not need a second syntax. nestjs-paginate expresses it through a separatefilter=expression language; here it is a prefix on the criterion itself —?filter.status=$eq:Active&filter.status=$or:$eq:Draft. The cost of that is symmetrical: there is no way to express boolean logic across different fields, which the expression form allows. Different fields are alwaysAND.
$contains is worth checking against your data. Here it means substring on a string field and set containment on a collection field, where all listed values must be present.
Configuration
The config is a fluent builder rather than an object literal, and the mapping is direct:
| nestjs-paginate | Here |
|---|---|
sortableColumns: ['name'] | .Sortable("name", p => p.Name) |
searchableColumns: ['name'] | .Searchable("name", p => p.Name) |
filterableColumns: { age: [FilterOperator.EQ] } | .Filterable("age", p => p.Age, PaginateFilterOperator.Eq) |
filterableColumns: { age: true } (all operators) | .Filterable("age", p => p.Age) — every operator the type supports, see operator defaults |
defaultSortBy: [['id', 'DESC']] | .DefaultSortBy("id", PaginateSortDirection.Desc) |
defaultLimit, maxLimit | .WithLimits(defaultLimit, maxLimit) — required unless shared defaults supply both |
| (no equivalent) | .WithTieBreaker(p => p.Id) — required; nestjs-paginate has no such rule, so this is one line to add per config when porting |
relations: { … } | not needed — the lambda names the navigation (p => p.Author!.Name), and a dotted field name is the convention; the projection decides what is loaded |
select: [...] | absent — the DTO decides |
where: { … } | absent — filter the IQueryable before paginating |
nullSort: 'last' | absent — null ordering is the provider's default |
ignoreSearchByInQueryParam | .IgnoreSearchByInQueryParam() |
updateGlobalConfig({ defaultLimit }) | PaginateConfigDefaults — shared explicitly per config, or once via .Shared; a config always overrides it. See Shared defaults |
The important shape difference: a column is named by a lambda, not a string, so a rename in the entity is a compile error rather than a runtime surprise, and the public alias is free to differ from the property name.
Two things have no counterpart there:
WithTieBreaker, and it is not optional in practice — see ordering below..When(...)and.ShowBadge(...), for a field only some callers may use, documented but conditionally enforced.
The response
Same three parts, different names in two places:
| nestjs-paginate | Here |
|---|---|
data | items |
meta.itemsPerPage / totalItems / currentPage / totalPages | same names |
| — | meta.itemCount — rows on this page, which has no counterpart there |
meta.sortBy (request echo) | same name, different shape: ["color:DESC"], our own wire form, not [["color","DESC"]] |
meta.search / searchBy (request echo) | same names, same idea; searchBy reports the fields that were actually searched |
meta.filter (request echo) | same name, always an array per field — {"status": ["$eq:Active"]}, never a bare string for a single criterion |
| — | meta.hasPreviousPage / hasNextPage — the two comparisons, pre-computed |
links.first / previous / next / last | same names |
links.current | same name, and never null |
| links are absolute URLs | links are path-relative (path base included), and the whole links object is null without a link context |
So the client-side changes that are not optional: read items instead of data, parse meta.sortBy as "field:DIR" strings rather than tuples, read every meta.filter value as an array even when there is one criterion, and build the URLs yourself if you were relying on them being absolute. See Response contract.
Ordering is stricter
nestjs-paginate lets a resource sort by whatever you configured and leaves it there. Here a resource cannot be configured without an ordering at all: WithTieBreaker is required, and a configuration that omits it throws InvalidOperationException out of Create — at startup, in your own code, not as a 400 to a caller. There is no request-time equivalent, and nothing a client sends can produce one.
The reason is the one offset paging always has: even with a sort, rows that tie on it can be shown twice or missed entirely, so a unique key is appended as the last ordering column. WithTieBreaker(p => p.Id) is therefore on every config here, without exception. If your NestJS resources relied on the database's incidental ordering, this is the one behavioural change worth planning for rather than discovering — and because it fails at build rather than per request, planning for it means running each config once at startup or in a test.
Limits and page size
WithLimits(defaultLimit, maxLimit) is required unless a shared defaults object supplies both halves, because the right page size is a property of the resource rather than of the library. Sharing is explicit either way — a config names the object, or a startup assignment does — so there is nothing ambient to inherit by accident.
A limit above maxLimit is rejected with a 400, not reduced. A client that asked for 500 and quietly received 100 would page through the collection wrongly, so the request fails instead.
limit=-1 exists, but only where the resource opted in with AllowUnlimited(maxRows), and the row ceiling is mandatory — there is no way to express a genuinely unbounded read. Where it is not enabled, -1 is an ordinary out-of-range limit and a caller who needs the whole set walks it in pages; see Pagination without ASP.NET Core for the loop.
Columns are not client-selectable
There is no select parameter, and this is a deliberate difference rather than a missing feature. What comes back is decided by the projection you chose on the server — the DTO's shape, or a selector you wrote — so a caller cannot widen the SELECT list, reach a column you did not intend to expose, or turn a narrow query into a wide one.
Where nestjs-paginate would use select and relations, pick a projection strategy instead: one DTO per shape you want to serve, and a different endpoint if a caller genuinely needs a different shape.
Errors
Both reject bad input with a 400. Here every message comes from one exception type and the wording is part of the published contract — the full list is in Errors. If your clients matched on NestJS validation-pipe messages, that matching has to be rewritten; matching on the status code does not.