|
| 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