Skip to content
Guide/Pagination

Pagination

Generate the URI, fetch it yourself, then hand the raw body to a Paginator. What comes back is a PaginatedCollection — rows plus the page metadata the backend reported.

const body = await fetch(`https://example.com/api${uri}`).then((r) => r.json());
const page = paginator.paginate(body);
page.data; // rows
page.total; // 57
page.lastPage; // 6
page.from; // 26
page.to; // 50

Paginator also writes that metadata back into the store, which is what lets the builder answer questions about where you are.

paginate<T>() types page.data as T[]. T can be an interface, a class or a type alias — anything that is an object:

interface Article {
id: number;
title: string;
}
const page = paginator.paginate<Article>(body);
page.data[0].title; // string

Passing a page across a serialisation boundary

Section titled “Passing a page across a serialisation boundary”

PaginatedCollection is a class, and some boundaries accept only plain data — React Server Components refuse to pass class instances to Client Components. toPlain() returns a copy that crosses them:

const page = paginator.paginate<Article>(body).toPlain();
page.data; // Article[] — a new array, the same rows
page.total; // 57
page.nextPageUrl; // null — this backend reports no links

The result is a PaginatedResult<T>: an object literal with data and every pagination field. A field the backend did not report is null rather than undefined, so JSON.stringify keeps every key. Rows pass through as the response held them.

Some backends page over headers rather than the body — PostgREST’s Content-Range, WordPress’s X-WP-TotalPages, the total-count of Rails’ Pagy. Pass the headers as the second argument:

const response = await fetch(url);
const page = paginator.paginate(await response.json(), response.headers);

Anything with a .get() accessor works — the DOM Headers, Angular’s HttpHeaders — as does a plain object. The core imports none of them.

PostgREST’s Content-Range places the rows but does not state the page size, so the paginator takes it from the store’s limit: a short last page, 30-33/34 at a limit of 10, is page 4 of 4. Parse each response with the paginator whose builder made the request, and keep the limit at or below the server’s db-max-rows (Supabase’s Max Rows, 1000 by default): PostgREST returns no more rows than that, and the next page’s offset would skip the rest.

Drivers that need to send pagination headers expose them through paginationHeaders(), which returns null when the active driver has nothing to add, so it is safe to spread unconditionally:

await fetch(url, { headers: { ...qb.paginationHeaders() } });

Every navigator returns this, so a move and a regenerate is one chain:

const nextUri = qb.nextPage().generateUri();

Out-of-range moves are clamped, not thrown — nextPage() on the last page is a no-op.

Until a response has been parsed, the builder does not know how many pages exist. Rather than guess, it throws:

qb.totalPages();
// PaginationNotSyncedError: Cannot read totalPages: no paginated response has been synced yet.

Paginator.paginate() fixes that for you. If you parse responses yourself, sync manually:

store.syncLastPage(page.lastPage);

PaginatedCollection.normalize() reduces a page to identifiers, keyed by page number — useful when rows live in a normalised store:

page.normalize(); // { 2: [7, 9] }
page.normalize('slug'); // { 2: ['first', 'second'] }
page.normalize((row) => `row-${row.id}`); // { 2: ['row-7', 'row-9'] }

Four helpers do the arithmetic every list control repeats. They render nothing — the markup is yours.

getPageWindow() lists the places a pagination bar shows, with 'gap' where a run of pages is folded:

import { getPageWindow } from '@qubeejs/core';
getPageWindow(5, 12); // [1, 'gap', 4, 5, 6, 'gap', 12]
getPageWindow(1, 12); // [1, 2, 3, 4, 5, 'gap', 12]
getPageWindow(12, 12); // [1, 'gap', 8, 9, 10, 11, 12]
getPageWindow(3, 5); // [1, 2, 3, 4, 5]

The window keeps a constant length — 2 × boundaries + 2 × siblings + 3, seven by default, or every page when there are fewer — so the buttons do not shift as the user pages, and a gap never hides a single page. boundaries sets how many pages show at each end, siblings how many either side of the current one; both are whole numbers, 0 or more:

getPageWindow(10, 20, { boundaries: 2, siblings: 2 });
// [1, 2, 'gap', 8, 9, 10, 11, 12, 'gap', 19, 20]

It never throws: a page outside 1–lastPage is clamped, and a last page that is 0, NaN or Infinity — a backend that reports no count — is treated as 1, so the window is just [1], without the current page. Such a backend cannot drive numbered pages: offer previous and next links instead.

getPageRange() gives the numbers; the wording stays yours:

import { getPageRange } from '@qubeejs/core';
const { from, to, total } = getPageRange(page); // a PaginatedCollection, or its toPlain()
`${from}–${to} of ${total}`; // '21–34 of 34'

It uses the backend’s own from and to when the driver reports them, and otherwise computes them from the page, the page size and the rows. An empty page is { from: 0, to: 0, total }; total is null when the backend does not count.

import { getAriaSort, toggleSort } from '@qubeejs/core';
toggleSort(sorts, 'title'); // title ascending — or flipped, if it is the primary sort
toggleSort(sorts, 'title', { multiple: true }); // keeps the other sorts
getAriaSort(sorts, 'title'); // 'ascending' | 'descending' | 'none'

toggleSort() replaces the other sorts unless multiple is set, and flips a field only when it is the primary sort — the one getAriaSort() announces; any other field starts ascending. With multiple, a field flips in place wherever it is. getAriaSort() reports a direction only for the primary sort — ARIA allows one sorted column per table — and 'none' for every other column.