---
title: "Pagination"
description: "Grow a list page by page with usePaginatedQuery, follow the four load statuses, and apply optimistic writes across every page already loaded."
canonical_url: "https://nuxt-convex-module.dev/guide/pagination"
---
# Pagination

> Grow a list page by page with usePaginatedQuery, follow the four load statuses, and apply optimistic writes across every page already loaded.

`usePaginatedQuery` turns a paginated Convex query into a growing list — ideal for infinite scroll — while keeping every loaded page live.

```ts
const { results, status, isLoading, loadMore } = usePaginatedQuery(
  api.messages.list,
  {},
  { initialNumItems: 20 },
)
```

Add a task or toggle one and watch it update with zero round-trip latency:

<playground-tasks>



</playground-tasks>

```vue [components/TaskList.vue]
<script setup lang="ts">
import type { Id } from '#convex/dataModel'
import { api } from '#convex/api'
import { insertAtTop, optimisticallyUpdateValueInPaginatedQuery } from 'nuxt-convex-module/client'

const { results, status, isLoading, loadMore } = usePaginatedQuery(
  api.tasks.listPaginated,
  {},
  { initialNumItems: 5 },
)

const addTask = useMutation(api.tasks.add).withOptimisticUpdate(
  (localStore, args) => {
    insertAtTop({
      paginatedQuery: api.tasks.listPaginated,
      localQueryStore: localStore,
      item: {
        _id: crypto.randomUUID() as Id<'tasks'>,
        _creationTime: Date.now(),
        text: args.text,
        completed: false,
      },
    })
  },
)

const toggleTask = useMutation(api.tasks.toggle).withOptimisticUpdate(
  (localStore, args) => {
    optimisticallyUpdateValueInPaginatedQuery(
      localStore,
      api.tasks.listPaginated,
      {},
      task => task._id === args.id ? { ...task, completed: !task.completed } : task,
    )
  },
)
</script>

<template>
  <label v-for="task in results" :key="task._id">
    <input type="checkbox" :checked="task.completed" @change="toggleTask({ id: task._id })">
    {{ task.text }}
  </label>
  <button :disabled="status !== 'CanLoadMore'" @click="loadMore(5)">
    Load more
  </button>
</template>
```

## Statuses

`status` walks through four values: `LoadingFirstPage` while the initial page streams in, then `CanLoadMore` when a `loadMore(n)` call would fetch another page, `LoadingMore` while that fetch is in flight, and finally `Exhausted` once the query has no more rows. `isLoading` is a shorthand for the two loading states.

`results`, `status`, and `isLoading` are `ComputedRef`s — destructure them once and they stay reactive (use `.value` in script, bare in templates) — while `loadMore` is a stable function you can call from anywhere. Like `useQuery`, reading any of the fields throws the query error to the nearest `errorCaptured` boundary.

`initialNumItems` is required and must be a number, `0` or more; the composable throws at setup otherwise. If the server rejects a stored cursor — the query's definition or index changed under it — the composable logs `usePaginatedQuery hit error, resetting pagination state` and starts again from the first page instead of throwing.

## Optimistic updates over pages

Updating a paginated query by hand means finding the right page. Four helpers (same names and shapes as `convex/react`, importable from `nuxt-convex-module/client`) do it for you:

<table>
<thead>
  <tr>
    <th>
      Helper
    </th>
    
    <th>
      What it does
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        insertAtTop({ paginatedQuery, argsToMatch?, localQueryStore, item })
      </code>
    </td>
    
    <td>
      Prepends <code>
        item
      </code>
      
       to the first page. No-op until the first page has loaded.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        insertAtBottomIfLoaded({ paginatedQuery, argsToMatch?, localQueryStore, item })
      </code>
    </td>
    
    <td>
      Appends <code>
        item
      </code>
      
       to the final page — only if the last page has loaded.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        insertAtPosition({ paginatedQuery, argsToMatch?, sortOrder, sortKeyFromItem, localQueryStore, item })
      </code>
    </td>
    
    <td>
      Inserts <code>
        item
      </code>
      
       at its sorted position across the loaded pages.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        optimisticallyUpdateValueInPaginatedQuery(localStore, query, args, updateValue)
      </code>
    </td>
    
    <td>
      Maps <code>
        updateValue
      </code>
      
       over every loaded item of the paginated query matching <code>
        args
      </code>
      
      .
    </td>
  </tr>
</tbody>
</table>

For a chat UI backed by `usePaginatedQuery(api.messages.list, ...)`, show the sent message instantly:

```ts
import { insertAtTop } from 'nuxt-convex-module/client'
import { api } from '#convex/api'
import type { Id } from '#convex/dataModel'

const sendMessage = useMutation(api.messages.send).withOptimisticUpdate(
  (localStore, args) => {
    insertAtTop({
      paginatedQuery: api.messages.list,
      argsToMatch: { channel: args.channel },
      localQueryStore: localStore,
      item: {
        _id: crypto.randomUUID() as Id<'messages'>,
        _creationTime: Date.now(),
        channel: args.channel,
        body: args.body,
      },
    })
  },
)

await sendMessage({ channel: '#general', body: 'hi' })
```

The temporary `_id` is replaced by the real document when the mutation's results sync.

## `useAsyncPaginatedQuery()` — SSR first page

`usePaginatedQuery` renders an empty list during SSR and fetches the first page on the client. `useAsyncPaginatedQuery` fetches that page on the server instead and embeds it in the Nuxt payload, like [`useAsyncQuery`](https://nuxt-convex-module.dev/guide/server-and-ssr#useasyncquery-ssr-data-the-nuxt-way); after hydration `usePaginatedQuery` takes over, so the list is live and `loadMore` works as above.

```vue [pages/feed.vue]
<script setup lang="ts">
import { api } from '#convex/api'

const { results, status, isLoading, error, loadMore } = useAsyncPaginatedQuery(
  api.messages.list,
  {},
  { initialNumItems: 20 },
)
</script>
```

The same `{ results, status, isLoading, loadMore }` as `usePaginatedQuery`, plus `error` and `refresh` from the Nuxt data contract. Until the live subscription has its first page, `results` is the server page and `status` is derived from it (`CanLoadMore` or `Exhausted`); a `loadMore` call in that window is a no-op. The server page belongs to the initial args: after a reactive args change the list is empty until the new first page arrives. The query error lands in `error` instead of being thrown. Options are `initialNumItems` plus `useAsyncQuery`'s `key`, `server`, `lazy` and `token`; client-side navigation opens the subscription directly and fetches nothing over HTTP.

## `usePaginatedQuery_experimental()`

<callout icon="i-lucide-flask-conical">

Experimental — upstream marks it so, and it may change in a minor release, as does `resetPaginationId` (a test hook that restarts the id sequence). See [experimental symbols](https://nuxt-convex-module.dev/getting-started/introduction#experimental).

</callout>

A dual-overload variant: the positional form matches `usePaginatedQuery` above, while the object form takes a single (optionally reactive) options object — `{ query, args, initialNumItems, throwOnError? }` — and returns one `ComputedRef` of a lowercase-status union:

```ts
const page = usePaginatedQuery_experimental({
  query: api.messages.list,
  args: {},
  initialNumItems: 20,
})
// page.value: { data, status: 'pending' | 'success' | 'error',
//               canLoadMore, isLoading, error, loadMore }
```

On error (without `throwOnError`), `data` is an empty array — never the previously loaded pages.


## Sitemap

See the full [sitemap](https://nuxt-convex-module.dev/sitemap.md) for all pages.
