Pagination in GraphQL
Pagination is essential for handling large datasets. Instead of loading all records at once, you load them in chunks. GraphQL offers several pagination patterns.
Offset-Based Pagination
The simplest approach uses offset and limit parameters to skip and limit results:
type Query {
posts(offset: Int, limit: Int): [Post!]!
}
# Schema
type Post {
id: ID!
title: String!
content: String!
}
# Query
query {
posts(offset: 0, limit: 10) {
id
title
}
}
Offset pagination is simple but has a problem: if data changes between requests, you might miss or duplicate items.
Cursor-Based Pagination
Cursor-based pagination uses a pointer to the last item you fetched. It's more reliable than offset-based pagination.
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
# Query first 10 posts
query {
posts(first: 10) {
edges {
node {
id
title
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Query next page
query {
posts(first: 10, after: "cursor-from-previous-query") {
edges {
node {
id
title
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
This is the Relay specification for pagination. It's more complex but handles edge cases better.
Implementing Cursor Pagination
Here's how to implement cursor-based pagination in your resolvers:
const resolvers = {
Query: {
posts: async (parent, args, context) => {
const { first, after, last, before } = args;
// Build the query
let query = {};
if (after) {
query.createdAt = { $gt: decodeCursor(after) };
}
if (before) {
query.createdAt = { $lt: decodeCursor(before) };
}
// Fetch one extra item to check if there's a next page
const limit = first || last;
const items = await context.db.posts
.find(query)
.sort({ createdAt: 1 })
.limit(limit + 1);
const hasNextPage = items.length > limit;
const edges = items.slice(0, limit).map(item => ({
node: item,
cursor: encodeCursor(item.createdAt),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!before,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: await context.db.posts.countDocuments(),
};
},
},
};
The key is to fetch one extra item to determine if there's a next page.
Client-Side Pagination
On the client, use Apollo Client's fetchMore to load additional pages:
import { useQuery, gql } from '@apollo/client';
const GET_POSTS = gql`
query GetPosts($first: Int, $after: String) {
posts(first: $first, after: $after) {
edges {
node {
id
title
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function PostList() {
const { data, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 10 },
});
const loadMore = () => {
fetchMore({
variables: {
after: data.posts.pageInfo.endCursor,
},
});
};
return (
<div>
{data?.posts.edges.map(({ node }) => (
<div key={node.id}>{node.title}</div>
))}
{data?.posts.pageInfo.hasNextPage && (
<button onClick={loadMore}>Load More</button>
)}
</div>
);
}
Apollo Client automatically merges the new results with the existing cache.