Cursor-based Pagination ​
Cursor-based pagination uses an opaque identifier (a cursor) to mark a position in the list. The server returns the items after a given cursor along with a new cursor for the next page:
type Query {
feed(cursor: String, limit: Int!): FeedConnection!
}
type FeedConnection {
items: [FeedItem!]!
nextCursor: String
hasMore: Boolean!
}
type FeedItem {
id: ID!
message: String!
}Cursors are stable across mutations: adding or removing items between page fetches does not corrupt subsequent pages because each cursor points at a specific item, not an absolute position.
Two flavors of cursor pagination ​
This page covers two common shapes:
- Separate cursor and items. The response includes a
nextCursorfield alongside the items array. Recommended for most cases. - Relay-style connections. Items wrapped in
edgeswith per-item cursors plus apageInfoobject. Useful when consuming Relay-compatible APIs.
For the underlying merge and read mechanics, see Apollo's Cursor-based pagination.
Separate cursor: configuration ​
A simple merge function appends the new items and tracks the most recent cursor:
import { ApolloClient, InMemoryCache } from '@apollo/client'
const apolloClient = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
keyArgs: false, // Single merged list, regardless of cursor argument
merge(existing, incoming, { readField }) {
const items = existing ? { ...existing.items } : {}
incoming.items.forEach((item: any) => {
items[readField('id', item) as string] = item
})
return {
nextCursor: incoming.nextCursor,
hasMore: incoming.hasMore,
items,
}
},
read(existing) {
if (!existing)
return undefined
return {
nextCursor: existing.nextCursor,
hasMore: existing.hasMore,
items: Object.values(existing.items),
}
},
},
},
},
},
}),
// ...
})Storing items in a map keyed by id makes duplicate handling automatic: if the same id arrives again, it overwrites itself rather than duplicating in the list.
Separate cursor: usage ​
<script setup lang="ts">
const FEED_QUERY: TypedDocumentNode<{ feed: { items: Array<{ id: string, message: string }>, nextCursor: string | null, hasMore: boolean } }, { cursor?: string | null, limit: number }>
= gql`
query Feed($cursor: String, $limit: Int!) {
feed(cursor: $cursor, limit: $limit) {
items {
id
message
}
nextCursor
hasMore
}
}
`
const { current, fetchMore } = useQuery(FEED_QUERY, {
variables: { limit: 10 },
})
function loadMore() {
if (current.value.resultState !== 'complete')
return
if (!current.value.result.feed.hasMore)
return
fetchMore({
variables: {
cursor: current.value.result.feed.nextCursor,
limit: 10,
},
})
}
</script>
<template>
<ul v-if="current.resultState === 'complete'">
<li v-for="item in current.result.feed.items" :key="item.id">
{{ item.message }}
</li>
</ul>
<button
v-if="current.resultState === 'complete' && current.result.feed.hasMore"
@click="loadMore"
>
Load more
</button>
</template><script setup lang="ts">
import { ApolloQuery } from '@vue/apollo-components'
</script>
<template>
<ApolloQuery :query="Feed" :variables="{ limit: 10 }">
<template #data="{ data, loading, fetchMore }">
<ul>
<li v-for="item in data.feed.items" :key="item.id">
{{ item.message }}
</li>
</ul>
<button
v-if="data.feed.hasMore"
:disabled="loading"
@click="fetchMore({ variables: { cursor: data.feed.nextCursor, limit: 10 } })"
>
Load more
</button>
</template>
</ApolloQuery>
</template>#data only renders once there is a result, so data.feed.hasMore and data.feed.nextCursor are safe to read without checking for one first.
Each fetchMore call passes the cursor from the previous page. Apollo merges the new items into the cached list using the merge function configured above.
Relay-style connections ​
For APIs that follow the Relay Cursor Connections Specification, Apollo Client ships a helper:
import { InMemoryCache } from '@apollo/client'
import { relayStylePagination } from '@apollo/client/utilities'
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
comments: relayStylePagination(),
},
},
},
})The helper handles edges, pageInfo, and the standard cursor naming.
Usage:
<script setup lang="ts">
const COMMENTS_QUERY: TypedDocumentNode<{ comments: { edges: Array<{ node: { id: string, text: string } }>, pageInfo: { endCursor: string | null, hasNextPage: boolean } } }, { cursor?: string | null }>
= gql`
query Comments($cursor: String) {
comments(first: 10, after: $cursor) {
edges {
node {
id
text
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
`
const { current, fetchMore } = useQuery(COMMENTS_QUERY)
function loadMore() {
if (current.value.resultState !== 'complete')
return
if (!current.value.result.comments.pageInfo.hasNextPage)
return
fetchMore({
variables: {
cursor: current.value.result.comments.pageInfo.endCursor,
},
})
}
</script>
<template>
<ul v-if="current.resultState === 'complete'">
<li v-for="edge in current.result.comments.edges" :key="edge.node.id">
{{ edge.node.text }}
</li>
</ul>
<button @click="loadMore">
Load more
</button>
</template><script setup lang="ts">
import { ApolloQuery } from '@vue/apollo-components'
</script>
<template>
<ApolloQuery :query="Comments">
<template #data="{ data, fetchMore }">
<ul>
<li v-for="edge in data.comments.edges" :key="edge.node.id">
{{ edge.node.text }}
</li>
</ul>
<button
v-if="data.comments.pageInfo.hasNextPage"
@click="fetchMore({ variables: { cursor: data.comments.pageInfo.endCursor } })"
>
Load more
</button>
</template>
</ApolloQuery>
</template>Inserting new items into a paginated list ​
The cache cannot tell which page a newly-created item belongs to, so a mutation does not automatically appear in the paginated list. Options:
- Refetch the first page after creating an item.
- Write to the cache through
cache.modifyto insert the item into the merged list directly. - For optimistic UI, use an
optimisticResponseplus anupdatecallback that inserts into the merged list.
See Cache Updates for the insertion patterns.
<ApolloMutation> has props only for mutation, variables and clientId. update, refetchQueries and optimisticResponse all reach useMutation through the options prop, which takes the full useMutation.Options object:
<ApolloMutation
v-slot="{ mutate }"
:mutation="PostFeedItem"
:options="{ optimisticResponse: pendingItem, update: insertIntoFeed }"
@error="console.error"
>
<button @click="mutate({ variables: { message } })">
Post
</button>
</ApolloMutation>Key arguments ​
If your paginated field also accepts filter or sort arguments, mark those as keyArgs so each filter value gets its own merged list:
const fields = {
feed: {
keyArgs: ['filter', 'sortOrder'],
// ...
},
}Otherwise, switching filters would merge incompatible pages together.
Next steps ​
- Pagination Overview compares offset and cursor strategies.
- Offset-based for the simpler offset strategy.
- Apollo Client's Cursor-based pagination for advanced
mergeandreadpatterns.