Any list that can grow needs pagination designed into the schema from day one.
Never expose an unbounded list such as posts: [Post!]! on a growing collection. Two styles exist:
- Offset:
posts(limit: 20, offset: 40). Simple, supports jumping to page 7, but items shift when rows are inserted, and large offsets are slow in most databases. - Cursor:
posts(first: 20, after: "cursor"). The cursor is an opaque string (often base64 of the last sort key). Stable under inserts and efficient with keyset queries.
The widely used connection pattern (from Relay, now a general convention) standardizes cursors:
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int }
type PostEdge { node: Post! cursor: String! }
type PageInfo { hasNextPage: Boolean! endCursor: String hasPreviousPage: Boolean! startCursor: String }Clients request first/after to go forward or last/before to go back. Edges are a place for relationship data (e.g. role on a team membership edge). Many APIs also add a nodes shortcut field.
Server tips: cap first (reject or clamp values above, say, 100), make cursors opaque so you can change their encoding, and implement keyset pagination (WHERE (created_at, id) < (...)) rather than translating cursors back into offsets.
Example
query Feed($after: String) {
posts(first: 10, after: $after) {
edges {
cursor
node { id title }
}
pageInfo { hasNextPage endCursor }
}
}
# Next page: send variables { "after": "<pageInfo.endCursor>" }
# Stop when pageInfo.hasNextPage is false.Remember
- Cursor pagination is stable under inserts; offset is not
- Connections: edges { cursor node } plus pageInfo
- Cap page sizes and keep cursors opaque
Watch out for
- Returning unbounded lists that grow until responses time out.