diff --git a/website/pages/docs/_meta.ts b/website/pages/docs/_meta.ts index 3c5bd7a419..569cef8996 100644 --- a/website/pages/docs/_meta.ts +++ b/website/pages/docs/_meta.ts @@ -19,6 +19,7 @@ const meta = { 'constructing-types': '', 'oneof-input-objects': '', 'defer-stream': '', + 'cursor-based-pagination': '', 'custom-scalars': '', 'advanced-custom-scalars': '', 'n1-dataloader': '', diff --git a/website/pages/docs/cursor-based-pagination.mdx b/website/pages/docs/cursor-based-pagination.mdx new file mode 100644 index 0000000000..5b548be264 --- /dev/null +++ b/website/pages/docs/cursor-based-pagination.mdx @@ -0,0 +1,303 @@ +--- +title: Implementing Cursor-based Pagination +--- + +When a GraphQL API returns a list of data, pagination helps avoid +fetching too much data at once. Cursor-based pagination fetches items +relative to a specific point in the list, rather than using numeric offsets. +This pattern works well with dynamic datasets, where users frequently add or +remove items between requests. + +GraphQL.js doesn't include cursor pagination out of the box, but you can implement +it using custom types and resolvers. This guide shows how to build a paginated field +using the connection pattern popularized by [Relay](https://relay.dev). By the end of this +guide, you will be able to define cursors and return results in a consistent structure +that works well with clients. + +## The connection pattern + +Cursor-based pagination typically uses a structured format that separates +pagination metadata from the actual data. The most widely adopted pattern follows the +[Relay Cursor Connections Specification](https://relay.dev/graphql/connections.htm). While +this format originated in Relay, many GraphQL APIs use it independently because of its +clarity and flexibility. + +This pattern wraps your list of items in a connection type, which includes the following fields: + +- `edges`: A list of edge objects, each representing an item in the list. +- `node`: The actual object you want to retrieve, such as user, post, or comment. +- `cursor`: An opaque string that identifies the position of the item in the list. +- `pageInfo`: Metadata about the list, such as whether more items are available. + +The following query and response show how this structure works: + +```graphql +query { + users(first: 2) { + edges { + node { + id + name + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +```json +{ + "data": { + "users": { + "edges": [ + { + "node": { + "id": "1", + "name": "Ada Lovelace" + }, + "cursor": "cursor-1" + }, + { + "node": { + "id": "2", + "name": "Alan Turing" + }, + "cursor": "cursor-2" + } + ], + "pageInfo": { + "hasNextPage": true, + "endCursor": "cursor-2" + } + } + } +} +``` + +This structure gives clients everything they need to paginate. It provides the actual data (`node`), +the cursor to continue from (`endCursor`), and a flag (`hasNextPage`) that indicates whether +more data is available. + +## Defining connection types in GraphQL.js + +To support this structure in your schema, define a few custom types: + +```js +const PageInfoType = new GraphQLObjectType({ + name: 'PageInfo', + fields: { + hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) }, + hasPreviousPage: { type: new GraphQLNonNull(GraphQLBoolean) }, + startCursor: { type: GraphQLString }, + endCursor: { type: GraphQLString }, + }, +}); +``` + +The `PageInfo` type provides metadata about the current page of results. +The `hasNextPage` and `hasPreviousPage` fields indicate whether more +results are available in either direction. The `startCursor` and `endCursor` +fields help clients resume pagination from a specific point. + +Next, define an edge type to represent individual items in the connection: + +```js +const UserEdgeType = new GraphQLObjectType({ + name: 'UserEdge', + fields: { + node: { type: UserType }, + cursor: { type: new GraphQLNonNull(GraphQLString) }, + }, +}); +``` + +Each edge includes a `node` and a `cursor`, which marks its position in +the list. + +Then, define the connection type itself: + +```js +const UserConnectionType = new GraphQLObjectType({ + name: 'UserConnection', + fields: { + edges: { + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(UserEdgeType)) + ), + }, + pageInfo: { type: new GraphQLNonNull(PageInfoType) }, + }, +}); +``` + +The connection type wraps a list of edges and includes the pagination +metadata. + +Paginated fields typically accept the following arguments: + +```js +const connectionArgs = { + first: { type: GraphQLInt }, + after: { type: GraphQLString }, + last: { type: GraphQLInt }, + before: { type: GraphQLString }, +}; +``` + +Use `first` and `after` for forward pagination. The `last` and `before` +arguments enable backward pagination if needed. + +## Writing a paginated resolver + +Once you've defined your connection types and pagination arguments, you can write a resolver +that slices your data and returns a connection object. The key steps are: + +1. Decode the incoming cursor. +2. Slice the data based on the decoded index. +3. Generate cursors for each returned item. +4. Build the `edges` and `pageInfo` objects. + +The exact logic will vary depending on how your data is stored. The following example uses an +in-memory list of users: + +```js +// Sample data +const users = [ + { id: '1', name: 'Ada Lovelace' }, + { id: '2', name: 'Alan Turing' }, + { id: '3', name: 'Grace Hopper' }, + { id: '4', name: 'Katherine Johnson' }, +]; + +// Encode/decode cursors +function encodeCursor(index) { + return Buffer.from(`cursor:${index}`).toString('base64'); +} + +function decodeCursor(cursor) { + const decoded = Buffer.from(cursor, 'base64').toString('ascii'); + const match = decoded.match(/^cursor:(\d+)$/); + return match ? parseInt(match[1], 10) : null; +} + +// Resolver for paginated users +const usersField = { + type: UserConnectionType, + args: connectionArgs, + resolve: (_, args) => { + let start = 0; + if (args.after) { + const index = decodeCursor(args.after); + if (index != null) { + start = index + 1; + } + } + + const slice = users.slice(start, start + (args.first || users.length)); + + const edges = slice.map((user, i) => ({ + node: user, + cursor: encodeCursor(start + i), + })); + + const startCursor = edges.length > 0 ? edges[0].cursor : null; + const endCursor = edges.length > 0 ? edges[edges.length - 1].cursor : null; + const hasNextPage = start + slice.length < users.length; + const hasPreviousPage = start > 0; + + return { + edges, + pageInfo: { + startCursor, + endCursor, + hasNextPage, + hasPreviousPage, + }, + }; + }, +}; +``` + +This resolver handles forward pagination using `first` and `after`. You can extend it to +support `last` and `before` by reversing the logic. + +## Using a database for pagination + +In production, you'll usually paginate data stored in a database. The same cursor-based +logic applies, but you'll translate cursors into SQL query parameters, typically +as an `OFFSET`. + +The following example shows how to paginate a list of users using PostgreSQL and a Node.js +client like `pg`: + +```js +const db = require('./db'); + +async function resolveUsers(_, args) { + const limit = args.first ?? 10; + let offset = 0; + + if (args.after) { + const index = decodeCursor(args.after); + if (index != null) { + offset = index + 1; + } + } + + const result = await db.query( + 'SELECT id, name FROM users ORDER BY id ASC LIMIT $1 OFFSET $2', + [limit + 1, offset] // Fetch one extra row to compute hasNextPage + ); + + const slice = result.rows.slice(0, limit); + const edges = slice.map((user, i) => ({ + node: user, + cursor: encodeCursor(offset + i), + })); + + const startCursor = edges.length > 0 ? edges[0].cursor : null; + const endCursor = edges.length > 0 ? edges[edges.length - 1].cursor : null; + + return { + edges, + pageInfo: { + startCursor, + endCursor, + hasNextPage: result.rows.length > limit, + hasPreviousPage: offset > 0, + }, + }; +} +``` + +This approach supports forward pagination by translating the decoded cursor into +an `OFFSET`. To paginate backward, you can reverse the sort order and slice the +results accordingly, or use keyset pagination for improved performance on large +datasets. + +## Handling edge cases + +When implementing pagination, consider how your resolver should handle the following scenarios: + +- **Empty result sets**: Return an empty `edges` array and a `pageInfo` object with +`hasNextPage: false` and `endCursor: null`. +- **Invalid cursors**: If decoding a cursor fails, treat it as a `null` or return an error, +depending on your API's behavior. +- **End of list**: If the requested `first` exceeds the available data, return all remaining +items and set `hasNextPage: false`. + +Always test your pagination with multiple boundaries: beginning, middle, end, and out-of-bounds +errors. + +## Additional resources + +To learn more about cursor-based pagination patterns and best practices, see: + +- [Relay Cursor Connections Specification](https://relay.dev/graphql/connections.htm) +- [Pagination](https://graphql.org/learn/pagination/) guide on graphql.org +- [`graphql-relay-js`](https://github.com/graphql/graphql-relay-js): Utility library for +building Relay-compatible GraphQL servers using GraphQL.js