Skip to content
Runtime
Framework

Pagination

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.

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.

// current URL: /products?page=2
await 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: /products

Use push — the runtime default — so Back returns to the previous page. Pagination is deliberate navigation, unlike a search field.

Next and Previous push a history entry each, so Back walks the pages. Searching resets to page 1 in a single transaction.

History1 / 1

Products

6 matching

  • Edge runtime handbookactive$59
  • Node.js request toolkitactive$39
  • Nuxt deployment guidearchived$19
Page 1 of 2
  • 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
/products

    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.

    Any change that shrinks the result set should return to page one, in the same transaction:

    // current URL: /products?status=active&page=4
    await runtime.transaction((draft) => {
    draft.status = "archived";
    draft.page = 1;
    });
    // written URL: /products?status=archived

    Two separate update calls would produce two history entries and a moment where the page number points past the end of the new results.

    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.

    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=newsletter

    createQueryUrl preserves query keys the model does not manage, so tracking parameters survive pagination links.

    • ?page=abc reports invalid and recovers to 1.
    • ?page=1&page=2 reports unexpected_multiple_values and uses the first.
    • ?page=99999999999999999999 exceeds the safe integer range and reports out_of_range.
    • A page beyond the results is not an error to QueryWeave. It is a bound only your data knows.