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>Each call to loadMore 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>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.
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.