Pagination, filtering and sorting
Module 6 covered how to page and filter in the database. This lesson is the other half: the contract you expose, which is what a client has to live with and what you cannot change once anything depends on it.
The envelope
{
"items": [...],
"page": 2,
"limit": 20,
"total": 47,
"totalPages": 3,
"hasNext": true,
"hasPrevious": true
}
Everything there earns its place:
total so the client can show "47 products".
totalPages so it can render page numbers without calculating — and
without getting the rounding wrong, which somebody will.
hasNext and hasPrevious so buttons can be disabled correctly at the
ends. A client can derive these, and three different clients will derive them
three slightly different ways.
Return the page and limit actually used, not what was requested. If a
caller asks for limit=1000 and you clamp to 100, the response must say 100 —
otherwise the client believes it has everything and stops paging.
That last point matters more than it looks and is easy to get wrong.
A reusable type
// packages/shared/src/pagination.ts
export interface Paginated<T> {
items: T[];
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrevious: boolean;
}
export interface PageQuery {
page?: number;
limit?: number;
}
export const DEFAULT_LIMIT = 20;
export const MAX_LIMIT = 100;
// apps/api/src/common/pagination.ts
export function paginate<T>(items: T[], total: number, page: number, limit: number): Paginated<T> {
const totalPages = Math.max(1, Math.ceil(total / limit));
return {
items,
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrevious: page > 1,
};
}
One helper, every list endpoint. The alternative is each endpoint building the envelope slightly differently, and a client that cannot write one function to handle them all.
Math.max(1, ...) so an empty result reports one page rather than zero, which
makes client-side page rendering simpler.
Naming parameters consistently
?page=2&limit=20&sort=price_asc&q=atta&category=staples
Use the same names across every endpoint. Products, orders and reviews
should all take page and limit. Mixing page/limit with offset/count
means every consumer looks it up each time.
| Parameter | Convention |
|---|---|
page |
1-based page number |
limit |
items per page |
sort |
a named sort, not a column |
q |
free-text search |
<field> |
filter by exact value — category=staples |
min<Field> / max<Field> |
ranges, with the unit named |
page is 1-based. Developers are used to zero-based arrays and users are
not, and the URL is user-visible. Pick 1 and document it.
Sorting as a named set
export const PRODUCT_SORTS = {
relevance: "relevance",
price_asc: "price_asc",
price_desc: "price_desc",
name: "name",
newest: "newest",
} as const;
export type ProductSort = keyof typeof PRODUCT_SORTS;
@IsOptional()
@IsIn(Object.keys(PRODUCT_SORTS))
sort?: ProductSort;
Never accept a raw column name. ?sort=passwordHash should not be a thing
a caller can attempt, and exposing column names ties your API to your schema —
renaming a column then breaks every client.
Named sorts also let you change the implementation. price_asc can move from
sorting a column to sorting a denormalised field without any client noticing.
Filters the client can render
A filter panel needs to know what the options are and how many results each would give:
{
"items": [...],
"total": 47,
"facets": {
"categories": [
{ "slug": "staples", "name": "Staples & Grains", "count": 24 },
{ "slug": "vegetables", "name": "Fresh Vegetables", "count": 12 }
],
"priceRange": { "minPaise": 2500, "maxPaise": 98000 }
}
}
Without this the client hard-codes the categories, and they go stale the moment the shop adds one.
The counts come from groupBy, from the queries lesson. They are an extra
query, so compute them only when asked:
?facets=true
Be careful how you count facets. Counting with the current category filter applied gives every other category a count of zero. Facet counts are normally computed with that dimension's own filter removed — so a user can see that switching to Vegetables would give 12 results.
That detail catches nearly everyone, and the symptom is a filter panel where every option except the selected one says zero.
Empty results are not errors
{ "items": [], "page": 1, "total": 0, "totalPages": 1, "hasNext": false }
200, not 404. The collection exists and contains nothing matching. 404 means the endpoint or resource does not exist, and returning it for an empty search forces clients to treat a normal outcome as a failure.
The same for a page beyond the end: return an empty list, not an error.
Stable ordering
From the queries lesson, and it is an API concern too: without a deterministic order, page 2 can repeat items from page 1.
Sorting by a non-unique column has the same problem — twenty products all named "Atta" have no defined order among themselves, so they can shuffle between requests.
Always add a unique tiebreaker:
orderBy: [{ pricePaise: "asc" }, { id: "asc" }]
Cheap, and it removes a bug that is very hard to diagnose from a report of "sometimes a product appears twice".
Searching
?q=atta
Three things worth doing:
Trim and cap the length. @MaxLength(100) stops somebody sending a
megabyte.
Treat empty as absent. ?q= should not filter to products whose name
contains an empty string — which matches everything, so it happens to work, and
then somebody adds a "relevance" sort that breaks on it.
Debounce on the client, not the server. Every keystroke hitting the API is the client's problem to solve, with the hook from module 2.
Performance limits worth having
Cap limit. 100 is generous.
Two layers, and they do different jobs. The DTO rejects anything above the cap with a 400 naming the limit, so a caller asking for 1000 is told rather than quietly given 100. The service clamps as well, because it can be called from somewhere that is not an HTTP request — a job, a script — where no DTO ran.
Silent clamping alone is worse than it looks: a client asking for 1000, getting 100, and not noticing will page through only a tenth of the data and believe it has everything.
Cap how deep pagination can go. ?page=500000 makes the database count
through ten million rows. A cap — or a cursor for deep pages — prevents a
trivial way to load your server.
Rate limit. NestJS has a throttler:
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }])
A public API without a rate limit can be scraped at whatever speed the caller likes.
Check your work
Why return the limit actually used: a defaulted or clamped request would
otherwise leave the client believing it received everything.
What a request above the cap should get: a 400 from the DTO naming the limit. The service clamps too, for callers that did not come through HTTP.
Why include totalPages and hasNext: so every client renders pagination
identically instead of deriving it three different ways.
Why page is 1-based: it appears in user-visible URLs, where zero-based is
confusing.
Why named sorts rather than column names: column names tie the API to the schema and let callers attempt fields you do not expose.
Why facet counts must exclude their own dimension's filter: otherwise every unselected option shows zero.
Why an empty result is 200: the collection exists and is empty. 404 means the resource does not exist.
Why a unique tiebreaker in orderBy: without one, rows with equal sort
values have no defined order and can repeat or vanish between pages.
Two limits worth capping: page size, and how deep pagination can go.
Practice
- Write the
Paginated<T>type inpackages/sharedand thepaginatehelper. Use it on two endpoints. - Request
?limit=500and confirm you get a 400 naming the limit. Then call the service directly with 500 and confirm it clamps to 100. - Request a page past the end. Confirm you get an empty list and a 200.
- Sort by a non-unique column and page through a list looking for a repeated
item. Add an
idtiebreaker and confirm it stops. - Accept a raw column name as
sortand try?sort=id. Then restrict it to a named set. - Add facet counts. Select a category and observe every other count become zero. Fix it.
- Send
?q=empty and confirm it does not filter. - Add the throttler and exceed the limit. Read the 429.
- Write one client function that renders pagination for any endpoint using the envelope. Confirm it works unchanged on two different endpoints.
Next: versioning, and not breaking your own front end.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship