Skip to main content

Pagination

Load a Convex paginated query with SSR, live pages, filters, and explicit pagination status.

Use useConvexPaginatedQuery for a collection that grows beyond one bounded result.

Backend query

convex/messages.ts
import { paginationOptsValidator } from 'convex/server'
import { v } from 'convex/values'

import { query } from './_generated/server'

export const list = query({
  args: {
    channelId: v.id('channels'),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('messages')
      .withIndex('by_channel_created', (q) => q.eq('channelId', args.channelId))
      .order('desc')
      .paginate(args.paginationOpts)
  },
})

Do not pass paginationOpts from the component. The composable owns cursors and page size.

Component

ts
const { results, status, isLoading, isStale, hasNextPage, loadMore, error, refresh, reset } =
  await useConvexPaginatedQuery(
    api.messages.list,
    { channelId },
    { initialNumItems: 20, auth: 'required' },
  )
vue
<template>
  <MessageList :messages="results" :aria-busy="isStale" />

  <button v-if="hasNextPage" :disabled="status === 'loading-more'" @click="loadMore(20)">
    {{ status === 'loading-more' ? 'Loading…' : 'Load more' }}
  </button>

  <p v-if="error">Could not load messages.</p>
</template>

Status values

StatusMeaning
idleSkipped or anonymous required query
loading-first-pageInitial page or refresh is loading
readyCurrent pages loaded; another page is available
loading-moreA later page is loading
exhaustedConvex reported no next page
errorCurrent page operation failed

Call loadMore() only when hasNextPage is true and the status is not already loading-more.

SSR and live pages

The first page can render during SSR. After hydration, each loaded page can have a live subscription. Unloaded pages are not present and cannot receive local live updates.

Filters and stale results

Use reactive arguments and keepPreviousData: true to retain old pages while the first page for a new filter loads. isStale marks that transition. Identity changes still clear all pages.

refresh() reloads the current pagination from the first page. reset() clears and starts the pagination controller again.

See infinite scroll for observer cleanup and accessible fallback controls.