Menu

Guides

View as Markdown

Pagination

Use this when a list should grow a page at a time without losing the first page you already rendered. Reach for a cursor query when the browser should keep one held list instance and append or prepend new rows into it.

Add the cursor query#

Start with after, limit, and a nextCursor:

ts
// Source-verified shape from examples/commerce/src/queries.ts
export const productGridQuery = query({
  access: publicAccess('public storefront browsing'),
  async load(input, context) {
    const { after, limit = 2 } = (input ?? {}) as { after?: string; limit?: number };
    const items = await context.db.select(...).from(products).where(after ? gt(products.id, after) : undefined).limit(limit);
    return { items, nextCursor: items.at(-1)?.id ?? null };
  },
});

The important shape is the return value. Kovo needs the current page rows plus the cursor for the next page.

Keep row identity stable#

Key the rendered rows and carry the cursor in markup:

tsx
// Source-verified shape from examples/commerce/src/components/product-grid.tsx
export const ProductGrid = component({
  queries: { productGrid: productGridQuery },
  render: ({ productGrid }) => (
    <section data-page-cursor={productGrid.nextCursor ?? ''}>
      {productGrid.items.map((item) => (
        <article kovo-key={item.id}>{item.name}</article>
      ))}
    </section>
  ),
});

kovo-key is the DOM identity. data-page-cursor is the next-page handoff.

Run it#

The commerce example proves the wire shape end to end. A new page is sent as a delta chunk, not a full re-ship of page one:

html
<kovo-query name="productGrid" delta
  >{"lists":{"items":{"key":"id","upsert":[{"id":"p3"},{"id":"p4"}]}}}</kovo-query
>

That exact shape is asserted in packages/server/src/wire-html.test.ts, and the commerce pagination test checks that re-applying the same page does not duplicate rows because the client reconciles by kovo-key.

Add the production shape#

If another mutation can add rows to the same collection, declare the keyed list delta on the query:

ts
// Source-verified shape from examples/commerce/src/queries.ts
export const orderHistoryQuery = query({
  async load() {
    return { items: [] };
  },
  delta: [{ domain: order.key, key: 'id', path: 'items' }],
});

That tells Kovo how one changed row fits back into the held list. Without it, the safe fallback is to re-send or re-render more than you wanted.

Use mode: 'prepend' on the page wire when the product is "load older" instead of "load more".

Handle failure#

Two mistakes show up fast:

  • If you forget kovo-key, the browser has no stable row identity for merge-time reconciliation.
  • If you forget to thread the cursor, the next fetch repeats the first page because nothing tells the server where page two begins.

When the framework cannot prove a keyed delta shape, it falls back to the coarser safe path instead of guessing.

Next#

Spec & diagnostics

Commerce source of truth: examples/commerce/src/queries.ts, examples/commerce/src/components/product-grid.tsx, and examples/commerce/src/pagination.test.ts. Delta wire shape: packages/server/src/wire-html.ts and packages/server/src/wire-html.test.ts. List delta merge contract: packages/core/src/query-delta.ts. Keyed reconciliation and held-list accumulation: SPEC spec/09-wire-protocol.md and spec/04-component-model.md section 13.2.

API reference: @kovojs/core, @kovojs/server.