Pagination
Load a Convex paginated query with immediate state, SSR, live pages, and explicit status.
Use useConvexPaginatedQuery for a collection that grows beyond one bounded result.
Backend query
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 controls cursors and page size.
Component
initialNumItems is required. The composable returns its reactive state immediately:
<script setup lang="ts">
import type { Id } from '~~/convex/_generated/dataModel'
import { api } from '#convex/api'
const props = defineProps<{ channelId: Id<'channels'> }>()
const {
data: messages,
status,
error,
canLoadMore,
isLoadingMore,
isExhausted,
loadMore,
} = useConvexPaginatedQuery(api.messages.list, () => ({ channelId: props.channelId }), {
initialNumItems: 20,
auth: 'required',
})
</script>
<template>
<p v-if="status === 'pending'">Loading messages…</p>
<p v-else-if="status === 'error'">Could not load messages.</p>
<template v-else>
<MessageList :messages="messages ?? []" />
<button v-if="canLoadMore || isLoadingMore" :disabled="!canLoadMore" @click="loadMore(20)">
{{ isLoadingMore ? 'Loading…' : 'Load more' }}
</button>
<p v-else-if="isExhausted">No older messages.</p>
<p v-if="error" role="alert">
Older messages could not be loaded. Select Load more to try again.
</p>
</template>
</template>In Nuxt, the return value is also a native Promise. Await it only when surrounding setup must wait for the first page:
const { data: messages, status } = await useConvexPaginatedQuery(api.messages.list, args, {
initialNumItems: 20,
})The awaited value is a separate non-Promise view of the same refs. Initial success, error, skip, and server: false resolve; inspect its state for the outcome.
Returned state
| Field | Meaning |
|---|---|
data | Every loaded item in order, or undefined before the first page exists |
status | Status of the first page: idle, pending, success, or error |
pending | true exactly when status is pending |
error | The first-page error, else the error of a failed later page, or undefined |
isStale | Same-identity previous pages are visible for new arguments |
blockedBy | Why the list is not running: 'skip', 'manual', 'auth', or null |
canLoadMore | The first page succeeded, the list has more items, and no later page is loading |
isLoadingMore | A later page is loading |
isExhausted | The last loaded page is the end of the list |
loadMore(n) | Request n more items; returns Promise<void> |
refresh() | Discard loaded pages and load again from the first page |
reset(c?) | Restart at the beginning or resume from a supplied cursor |
execute() | Start pagination created with immediate: false |
blockedBy has the same meaning as for useConvexQuery.
Status describes the first page
status and pending behave exactly as they do for a regular query. They describe the first page only. Loading a later page leaves status at success and sets isLoadingMore. Use status for the skeleton and error screen, and isLoadingMore for the load-more control.
Load more items
loadMore(n) requests n more items when canLoadMore is true. Otherwise it does nothing and resolves immediately, so a button or an intersection observer can call it without a separate guard. It throws synchronously when n is not a positive integer.
The returned Promise resolves when that page loads, fails, is replaced, or the list resets or is disposed. It never rejects, so a template can ignore it. Await it when the next step must wait for the page:
await loadMore(20)
scrollToEnd()canLoadMore is false while a later page loads, so only one later page loads at a time. During server rendering, only the first page loads; loadMore() resolves without effect.
A failed later page keeps loaded items
When a later page fails, the items loaded before it stay in data and status stays success. error holds the failure, and error.functionName names the query. canLoadMore becomes true again, and the next loadMore() call retries the failed page. A first-page failure sets status to error; use refresh() to retry it.
Options
Nuxt options include initialNumItems, initialCursor, auth,
keepPreviousData, immediate, lazy, and server. Plain Vue omits lazy
and server. lazy: true cannot be combined with immediate: false.
Use reactive arguments and keepPreviousData: true to retain old pages while the first page for a new filter loads. isStale marks that same-identity transition. Identity changes always clear every page.
Live pages
The first page can render during SSR. After hydration, every loaded page stays
live. When Convex asks to split a page that grew too large, the composable does
it for you. A page from an old request, an old argument set, or a previous user
never replaces newer results. Use initialCursor or reset(cursor) only when
the application resumes a position that it saved on purpose.
See infinite scroll for observer cleanup and accessible fallback controls.