Skip to content

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

ParameterHereNotes
pagesame1-based in both
limitsamebut see limits
sortBy=col:DESCsamerepeat the key for secondary sorts, same as there
searchsame
searchBysamerepeat the key; can be switched off per resource with IgnoreSearchByInQueryParam()
filter.<col>=$op:valuesame
select=id,nameabsentsee columns are not client-selectable
withDeletedabsentsoft delete is your IQueryable's business, not the paginator's
cursorabsentoffset paging only
filter= expressionabsentboolean 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> means AND in both. ?filter.rank=$gte:20&filter.rank=$lte:50 is a range either way.
  • OR does not need a second syntax. nestjs-paginate expresses it through a separate filter= 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 always AND.

$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-paginateHere
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:

The response

Same three parts, different names in two places:

nestjs-paginateHere
dataitems
meta.itemsPerPage / totalItems / currentPage / totalPagessame 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 / lastsame names
links.currentsame name, and never null
links are absolute URLslinks 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.

Released under the MIT License.