Skip to content

Commit dbb724d

Browse files
sarahxsandersJoviDeCroock
authored andcommitted
docs: cursor-based pagination guide (#4391)
Co-authored-by: Jovi De Croock <[email protected]>
1 parent ea440f9 commit dbb724d

File tree

2 files changed

+304
-0
lines changed

2 files changed

+304
-0
lines changed

website/pages/docs/_meta.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const meta = {
1919
'constructing-types': '',
2020
'oneof-input-objects': '',
2121
'defer-stream': '',
22+
'cursor-based-pagination': '',
2223
'custom-scalars': '',
2324
'advanced-custom-scalars': '',
2425
'n1-dataloader': '',
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
---
2+
title: Implementing Cursor-based Pagination
3+
---
4+
5+
When a GraphQL API returns a list of data, pagination helps avoid
6+
fetching too much data at once. Cursor-based pagination fetches items
7+
relative to a specific point in the list, rather than using numeric offsets.
8+
This pattern works well with dynamic datasets, where users frequently add or
9+
remove items between requests.
10+
11+
GraphQL.js doesn't include cursor pagination out of the box, but you can implement
12+
it using custom types and resolvers. This guide shows how to build a paginated field
13+
using the connection pattern popularized by [Relay](https://relay.dev). By the end of this
14+
guide, you will be able to define cursors and return results in a consistent structure
15+
that works well with clients.
16+
17+
## The connection pattern
18+
19+
Cursor-based pagination typically uses a structured format that separates
20+
pagination metadata from the actual data. The most widely adopted pattern follows the
21+
[Relay Cursor Connections Specification](https://relay.dev/graphql/connections.htm). While
22+
this format originated in Relay, many GraphQL APIs use it independently because of its
23+
clarity and flexibility.
24+
25+
This pattern wraps your list of items in a connection type, which includes the following fields:
26+
27+
- `edges`: A list of edge objects, each representing an item in the list.
28+
- `node`: The actual object you want to retrieve, such as user, post, or comment.
29+
- `cursor`: An opaque string that identifies the position of the item in the list.
30+
- `pageInfo`: Metadata about the list, such as whether more items are available.
31+
32+
The following query and response show how this structure works:
33+
34+
```graphql
35+
query {
36+
users(first: 2) {
37+
edges {
38+
node {
39+
id
40+
name
41+
}
42+
cursor
43+
}
44+
pageInfo {
45+
hasNextPage
46+
endCursor
47+
}
48+
}
49+
}
50+
```
51+
52+
```json
53+
{
54+
"data": {
55+
"users": {
56+
"edges": [
57+
{
58+
"node": {
59+
"id": "1",
60+
"name": "Ada Lovelace"
61+
},
62+
"cursor": "cursor-1"
63+
},
64+
{
65+
"node": {
66+
"id": "2",
67+
"name": "Alan Turing"
68+
},
69+
"cursor": "cursor-2"
70+
}
71+
],
72+
"pageInfo": {
73+
"hasNextPage": true,
74+
"endCursor": "cursor-2"
75+
}
76+
}
77+
}
78+
}
79+
```
80+
81+
This structure gives clients everything they need to paginate. It provides the actual data (`node`),
82+
the cursor to continue from (`endCursor`), and a flag (`hasNextPage`) that indicates whether
83+
more data is available.
84+
85+
## Defining connection types in GraphQL.js
86+
87+
To support this structure in your schema, define a few custom types:
88+
89+
```js
90+
const PageInfoType = new GraphQLObjectType({
91+
name: 'PageInfo',
92+
fields: {
93+
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
94+
hasPreviousPage: { type: new GraphQLNonNull(GraphQLBoolean) },
95+
startCursor: { type: GraphQLString },
96+
endCursor: { type: GraphQLString },
97+
},
98+
});
99+
```
100+
101+
The `PageInfo` type provides metadata about the current page of results.
102+
The `hasNextPage` and `hasPreviousPage` fields indicate whether more
103+
results are available in either direction. The `startCursor` and `endCursor`
104+
fields help clients resume pagination from a specific point.
105+
106+
Next, define an edge type to represent individual items in the connection:
107+
108+
```js
109+
const UserEdgeType = new GraphQLObjectType({
110+
name: 'UserEdge',
111+
fields: {
112+
node: { type: UserType },
113+
cursor: { type: new GraphQLNonNull(GraphQLString) },
114+
},
115+
});
116+
```
117+
118+
Each edge includes a `node` and a `cursor`, which marks its position in
119+
the list.
120+
121+
Then, define the connection type itself:
122+
123+
```js
124+
const UserConnectionType = new GraphQLObjectType({
125+
name: 'UserConnection',
126+
fields: {
127+
edges: {
128+
type: new GraphQLNonNull(
129+
new GraphQLList(new GraphQLNonNull(UserEdgeType))
130+
),
131+
},
132+
pageInfo: { type: new GraphQLNonNull(PageInfoType) },
133+
},
134+
});
135+
```
136+
137+
The connection type wraps a list of edges and includes the pagination
138+
metadata.
139+
140+
Paginated fields typically accept the following arguments:
141+
142+
```js
143+
const connectionArgs = {
144+
first: { type: GraphQLInt },
145+
after: { type: GraphQLString },
146+
last: { type: GraphQLInt },
147+
before: { type: GraphQLString },
148+
};
149+
```
150+
151+
Use `first` and `after` for forward pagination. The `last` and `before`
152+
arguments enable backward pagination if needed.
153+
154+
## Writing a paginated resolver
155+
156+
Once you've defined your connection types and pagination arguments, you can write a resolver
157+
that slices your data and returns a connection object. The key steps are:
158+
159+
1. Decode the incoming cursor.
160+
2. Slice the data based on the decoded index.
161+
3. Generate cursors for each returned item.
162+
4. Build the `edges` and `pageInfo` objects.
163+
164+
The exact logic will vary depending on how your data is stored. The following example uses an
165+
in-memory list of users:
166+
167+
```js
168+
// Sample data
169+
const users = [
170+
{ id: '1', name: 'Ada Lovelace' },
171+
{ id: '2', name: 'Alan Turing' },
172+
{ id: '3', name: 'Grace Hopper' },
173+
{ id: '4', name: 'Katherine Johnson' },
174+
];
175+
176+
// Encode/decode cursors
177+
function encodeCursor(index) {
178+
return Buffer.from(`cursor:${index}`).toString('base64');
179+
}
180+
181+
function decodeCursor(cursor) {
182+
const decoded = Buffer.from(cursor, 'base64').toString('ascii');
183+
const match = decoded.match(/^cursor:(\d+)$/);
184+
return match ? parseInt(match[1], 10) : null;
185+
}
186+
187+
// Resolver for paginated users
188+
const usersField = {
189+
type: UserConnectionType,
190+
args: connectionArgs,
191+
resolve: (_, args) => {
192+
let start = 0;
193+
if (args.after) {
194+
const index = decodeCursor(args.after);
195+
if (index != null) {
196+
start = index + 1;
197+
}
198+
}
199+
200+
const slice = users.slice(start, start + (args.first || users.length));
201+
202+
const edges = slice.map((user, i) => ({
203+
node: user,
204+
cursor: encodeCursor(start + i),
205+
}));
206+
207+
const startCursor = edges.length > 0 ? edges[0].cursor : null;
208+
const endCursor = edges.length > 0 ? edges[edges.length - 1].cursor : null;
209+
const hasNextPage = start + slice.length < users.length;
210+
const hasPreviousPage = start > 0;
211+
212+
return {
213+
edges,
214+
pageInfo: {
215+
startCursor,
216+
endCursor,
217+
hasNextPage,
218+
hasPreviousPage,
219+
},
220+
};
221+
},
222+
};
223+
```
224+
225+
This resolver handles forward pagination using `first` and `after`. You can extend it to
226+
support `last` and `before` by reversing the logic.
227+
228+
## Using a database for pagination
229+
230+
In production, you'll usually paginate data stored in a database. The same cursor-based
231+
logic applies, but you'll translate cursors into SQL query parameters, typically
232+
as an `OFFSET`.
233+
234+
The following example shows how to paginate a list of users using PostgreSQL and a Node.js
235+
client like `pg`:
236+
237+
```js
238+
const db = require('./db');
239+
240+
async function resolveUsers(_, args) {
241+
const limit = args.first ?? 10;
242+
let offset = 0;
243+
244+
if (args.after) {
245+
const index = decodeCursor(args.after);
246+
if (index != null) {
247+
offset = index + 1;
248+
}
249+
}
250+
251+
const result = await db.query(
252+
'SELECT id, name FROM users ORDER BY id ASC LIMIT $1 OFFSET $2',
253+
[limit + 1, offset] // Fetch one extra row to compute hasNextPage
254+
);
255+
256+
const slice = result.rows.slice(0, limit);
257+
const edges = slice.map((user, i) => ({
258+
node: user,
259+
cursor: encodeCursor(offset + i),
260+
}));
261+
262+
const startCursor = edges.length > 0 ? edges[0].cursor : null;
263+
const endCursor = edges.length > 0 ? edges[edges.length - 1].cursor : null;
264+
265+
return {
266+
edges,
267+
pageInfo: {
268+
startCursor,
269+
endCursor,
270+
hasNextPage: result.rows.length > limit,
271+
hasPreviousPage: offset > 0,
272+
},
273+
};
274+
}
275+
```
276+
277+
This approach supports forward pagination by translating the decoded cursor into
278+
an `OFFSET`. To paginate backward, you can reverse the sort order and slice the
279+
results accordingly, or use keyset pagination for improved performance on large
280+
datasets.
281+
282+
## Handling edge cases
283+
284+
When implementing pagination, consider how your resolver should handle the following scenarios:
285+
286+
- **Empty result sets**: Return an empty `edges` array and a `pageInfo` object with
287+
`hasNextPage: false` and `endCursor: null`.
288+
- **Invalid cursors**: If decoding a cursor fails, treat it as a `null` or return an error,
289+
depending on your API's behavior.
290+
- **End of list**: If the requested `first` exceeds the available data, return all remaining
291+
items and set `hasNextPage: false`.
292+
293+
Always test your pagination with multiple boundaries: beginning, middle, end, and out-of-bounds
294+
errors.
295+
296+
## Additional resources
297+
298+
To learn more about cursor-based pagination patterns and best practices, see:
299+
300+
- [Relay Cursor Connections Specification](https://relay.dev/graphql/connections.htm)
301+
- [Pagination](https://graphql.org/learn/pagination/) guide on graphql.org
302+
- [`graphql-relay-js`](https://github.com/graphql/graphql-relay-js): Utility library for
303+
building Relay-compatible GraphQL servers using GraphQL.js

0 commit comments

Comments
 (0)