Cursor-based Pagination with GraphQL Relay Connections
Implement Relay-style cursor pagination in GraphQL with edges, nodes, and pageInfo for efficient forward and backward traversal
Overview
Offset pagination breaks on live data: insert a row at the top of a feed and every subsequent page shifts, so clients see duplicates or miss items entirely. The Relay Connection spec solves this by giving each item a stable cursor — an opaque token the client hands back to ask for “what comes after this.” It’s the standard pagination shape for GraphQL APIs, expected by Relay and supported by every major client.
This recipe covers the full spec: Connection/Edge/PageInfo types, base64 cursor encoding, a resolver that paginates forward and backward, and the variant that matters in production — composite cursors for non-unique sort columns.
When to Use This
- Collections that grow over time and need stable pagination
- Clients that support infinite scroll or “load more” patterns
- APIs consumed by Relay, Apollo, or any client expecting cursor-based navigation
For a plain offset-paginated REST endpoint, see cursor pagination in PostgreSQL — same technique, different transport. Don’t use connections for tiny static lists (a dropdown of countries): the ceremony isn’t worth it there.
Prerequisites
- A GraphQL server (Apollo Server, GraphQL Yoga, or similar)
- A data source with a sortable, unique column (ID, timestamp, or cursor)
Solution
1. Define the Connection Types
// schema.ts
import gql from 'graphql-tag';
export const typeDefs = gql`
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
cursor: String!
node: Post!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type Post {
id: ID!
title: String!
content: String!
createdAt: String!
}
input PaginationInput {
first: Int
after: String
last: Int
before: String
}
type Query {
posts(pagination: PaginationInput): PostConnection!
}
`;
2. Implement Cursor Encoding
// cursor.ts
export function encodeCursor(value: string | number): string {
return Buffer.from(String(value)).toString('base64');
}
export function decodeCursor(cursor: string): string {
return Buffer.from(cursor, 'base64').toString('utf8');
}
3. Build the Resolver
// resolvers.ts
import { encodeCursor, decodeCursor } from './cursor';
interface Post {
id: string;
title: string;
content: string;
createdAt: string;
}
export const postResolvers = {
Query: {
posts: async (
_: unknown,
{ pagination }: { pagination: { first?: number; after?: string; last?: number; before?: string } },
context: { db: { posts: { findMany: (opts: any) => Promise<Post[]>; count: () => Promise<number> } } }
) => {
const { first, after, last, before } = pagination;
const limit = first ?? last ?? 10;
const maxLimit = 50;
const take = Math.min(limit, maxLimit);
let cursor: string | undefined;
let skip = 0;
let order: 'asc' | 'desc' = 'desc';
if (after) {
cursor = decodeCursor(after);
skip = 1;
} else if (before) {
cursor = decodeCursor(before);
skip = 1;
order = 'asc';
}
const posts = await context.db.posts.findMany({
take: take + 1,
skip,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { id: order },
});
const hasMore = posts.length > take;
const trimmed = hasMore ? posts.slice(0, take) : posts;
const reversed = last ? trimmed.reverse() : trimmed;
const edges = reversed.map((post) => ({
cursor: encodeCursor(post.id),
node: post,
}));
const totalCount = await context.db.posts.count();
// hasMore means "more rows exist in the direction you queried":
// forward (first/after) → hasNextPage; backward (last/before) → hasPreviousPage
return {
edges,
totalCount,
pageInfo: {
hasNextPage: before ? true : Boolean(first && hasMore),
hasPreviousPage: after ? true : Boolean(last && hasMore),
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
},
},
};
4. Query the Connection
query GetPosts($first: Int, $after: String) {
posts(pagination: { first: $first, after: $after }) {
edges {
cursor
node {
id
title
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
Pass the endCursor from the previous response as after in the next request to load the next page.
How It Works
- Cursors are opaque tokens encoding the last-seen position (typically the row ID). Clients treat them as black boxes.
- Edges pair each node with its cursor, so clients can navigate from any point without tracking offsets.
first+afterfetches forward;last+beforefetches backward. The resolver reverses results when paginating backward.take + 1is a trick to check for a next page without a separate count query — if you get more rows than requested,hasNextPageis true.
Variants
Offset-Based Fallback
For data sources without cursor support (Elasticsearch aggregations, legacy APIs), fall back to offset pagination but still return the connection shape for client compatibility:
const offset = after ? parseInt(decodeCursor(after), 10) + 1 : 0;
const posts = await db.posts.findMany({ skip: offset, take });
Keyset Pagination with Composite Cursors
For ordered columns that aren’t unique (like createdAt), use a composite cursor (createdAt, id) to avoid skipping rows with identical timestamps:
export function encodeCompositeCursor(createdAt: string, id: string): string {
return Buffer.from(`${createdAt}|${id}`).toString('base64');
}
Advanced: Pagination with Sort Order
When paginating by a non-id column (e.g., createdAt), encode the sort value in the cursor:
export function encodeSortCursor(value: string, id: string): string {
return Buffer.from(`${value}|${id}`).toString('base64');
}
export function decodeSortCursor(cursor: string): { value: string; id: string } {
const [value, id] = Buffer.from(cursor, 'base64').toString().split('|');
return { value, id };
}
The resolver uses a compound WHERE clause to skip rows seen in the previous page:
const { value, id } = after ? decodeSortCursor(after) : { value: null, id: null };
const posts = await db.posts.findMany({
where: value
? {
OR: [
{ createdAt: { lt: new Date(value) } },
{ createdAt: new Date(value), id: { lt: id } },
],
}
: undefined,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: take + 1,
});
Two posts created in the same second won’t disappear between pages.
Best Practices
- Cap
firstandlastat a reasonable maximum (50–100) to prevent expensive queries. - Always sort by a stable column — sorting by non-unique fields without a tiebreaker causes skipped rows.
- Keep cursors opaque — if clients can’t parse them, they can’t couple to your internals.
- Include
totalCountonly when the client needs it; it can be expensive on large tables. - Index the sort column — without it, every page request re-scans the table.
Common Mistakes
- Using offset as cursor — this defeats the purpose of cursor pagination and reintroduces skip/duplicate issues.
- Forgetting
skip: 1after a cursor — without it, the first item of each page repeats the last item of the previous page. - Not handling empty results — return an empty
edgesarray withhasNextPage: falseinstead of throwing. - Wrong pageInfo flags on
last/before— when paginating backward,hasPreviousPagecomes fromhasMoreandhasNextPageis true because you arrived from a later page.
Troubleshooting
- First item of each page duplicates the last of the previous page: you forgot
skip: 1(or the equivalent>filter) after the cursor — the query is re-fetching the cursor row itself. - Every page shows
hasNextPage: false: you’re fetchingtakerows instead oftake + 1, so there’s never a spillover row to detect more pages with. - Rows skipped when sorting by
createdAt: two rows share the same timestamp and the cursor only encodes that column. Switch to a composite(createdAt, id)cursor. aftercursor throws or returns page 1: the client sent a malformed or stale cursor (base64 that decodes to garbage, or an ID that no longer exists). Decode defensively and treat an undecodable cursor as “no cursor” or a 400 — your pick, just don’t crash the resolver.- Client passed both
firstandlast: the spec says pick one. Reject the combination or givefirstprecedence — silent surprises here produce confusing pages.
See Also
- GraphQL Connection Pagination Pattern — the architectural pattern behind this recipe.
- Cursor Pagination in PostgreSQL — the same keyset technique applied to a REST endpoint.
- Relay Connection spec — the normative reference for the shape implemented here.
Companion code: runnable Relay pagination server: the schema, cursor helpers, and resolver from this recipe wired to an Apollo Server with seeded data.
Frequently Asked Questions
Should I use cursor or offset pagination for GraphQL?
For GraphQL, cursors — they stay stable when rows insert or delete between requests, while offsets shift and skip. Reach for offset only if your data source can't do cursors at all.
How do I implement bidirectional pagination?
Support both first/after and last/before in your resolver. The hasPreviousPage flag tells clients whether a previous page exists.
Can I use Relay connections without the Relay client?
Yes — the connection spec is a schema convention, not a Relay-only feature. Apollo Client, urql, and graphql-request all handle it.
What should the cursor encode?
Typically the primary key or a composite of the sort column plus primary key. Avoid encoding offsets.
How do I handle pagination with sort order?
Encode the sort column value plus the primary key in the cursor (e.g., createdAt|id). The resolver uses a compound WHERE clause: rows where createdAt < cursor.createdAt, or where createdAt == cursor.createdAt AND id < cursor.id. Two rows sharing a timestamp can't silently vanish between pages.
Should I include totalCount in every response?
Only when the client asks for it — totalCount means a separate COUNT query, which gets expensive as the table grows. Keep it optional and skip the count when nobody requests the field.
How do I cap page size without breaking the spec?
Set a maximum for first and last (e.g., 50–100) in the resolver. If the client asks for more, clamp it — an uncapped first on a big table turns a page request into a full scan.
Related Resources
Build a GraphQL API with Apollo Server and TypeScript
How to build a production-ready GraphQL API using Apollo Server, TypeScript, and DataLoader to solve the N+1 query problem
RecipeCursor-Based Pagination in PostgreSQL (Keyset vs OFFSET)
Implement efficient cursor-based pagination for large datasets in PostgreSQL, avoiding OFFSET performance degradation with indexed keyset pagination and stable sort ordering
PatternGraphQL Batched Resolver Pattern
Resolve nested GraphQL fields in a single batched request to eliminate N+1 queries and reduce database load.
RecipeBatch and Cache Database Queries with GraphQL DataLoader
Use DataLoader to coalesce individual load requests into batched database calls, solving the N+1 query problem in GraphQL resolvers
PatternGraphQL Connection Pagination Pattern
Implement Relay-style cursor-based pagination with edges, nodes, and pageInfo for stable GraphQL list queries.