Pagination
The model
Section titled “The model”const products = defineQueryModel({ page: param.integer({ min: 1 }).default(1), perPage: param.integer({ min: 1, max: 100 }).default(20),});Both bounds do real work. min: 1 turns ?page=0 and ?page=-3 into an out_of_range issue with
a recovery to 1, and max: 100 stops ?perPage=100000 from becoming a denial-of-service vector
against your own database.
Page one is absent
Section titled “Page one is absent”products.encode({ page: 1, perPage: 20 }); // []products.encode({ page: 2, perPage: 20 }); // [["page", "2"]]/products and /products?page=1 are the same state, and only one of them is ever produced. This
matters more than it looks: it halves the number of URLs your cache, your analytics, and your
canonical tags have to reconcile.
Moving between pages
Section titled “Moving between pages”// current URL: /products?page=2await runtime.update({ page: 3 });// written URL: /products?page=3
await runtime.update({ page: 2 });// written URL: /products?page=2
await runtime.reset(["page"]); // back to page 1, key removed// written URL: /productsUse push — the runtime default — so Back returns to the previous page. Pagination is deliberate
navigation, unlike a search field.
History1 / 1
- history.pushState
- history.replaceState
- popstate
The adapter writes through the History API and re-reads on popstate.
Type a query, press Enter.
Typed state
{
"page": 1,
"sort": "created_at",
"status": "all"
}Canonical URL
Valid/productsGuarding the upper bound
Section titled “Guarding the upper bound”The model cannot know how many pages exist — that depends on data. Clamp after you know:
const { rows, total } = await fetchProducts(values);const pageCount = Math.max(1, Math.ceil(total / values.perPage));
if (values.page > pageCount) { await runtime.update({ page: pageCount }, { navigation: "replace" });}Use replace here. The reader did not ask to visit a page that does not exist, so it should not
occupy a history entry.
Resetting on filter changes
Section titled “Resetting on filter changes”Any change that shrinks the result set should return to page one, in the same transaction:
// current URL: /products?status=active&page=4await runtime.transaction((draft) => { draft.status = "archived"; draft.page = 1;});// written URL: /products?status=archivedTwo separate update calls would produce two history entries and a moment where the page number
points past the end of the new results.
On the server
Section titled “On the server”const result = readRequestQuery(request, products);const values = result.ok ? result.value : { ...products.defaults(), ...result.partial };
const offset = (values.page - 1) * values.perPage;values.page is a bounded number here without any further checking, because the model already
rejected everything else. That is the point of decoding on both sides with one description.
Building page links
Section titled “Building page links”import { createQueryUrl } from "@queryweave/server";
const requestUrl = "https://shop.example/products?page=2&utm_source=newsletter";const next = createQueryUrl(requestUrl, products, { page: 3, perPage: 20 });next.href; // https://shop.example/products?page=3&utm_source=newslettercreateQueryUrl preserves query keys the model does not manage, so tracking parameters survive
pagination links.
Edge cases
Section titled “Edge cases”?page=abcreportsinvalidand recovers to1.?page=1&page=2reportsunexpected_multiple_valuesand uses the first.?page=99999999999999999999exceeds the safe integer range and reportsout_of_range.- A page beyond the results is not an error to QueryWeave. It is a bound only your data knows.