Paginacion por cursores con GraphQL Relay Connections
Implementa paginacion estilo Relay con cursores en GraphQL usando edges, nodes y pageInfo para recorrido eficiente hacia adelante y atras
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Paginacion por cursores con GraphQL Relay Connections
La especificacion Relay Connection es el estandar de facto para paginar resultados en GraphQL. Modela las colecciones como conexiones que contienen edges, donde cada edge envuelve un node y un cursor. Esta estructura soporta paginacion estable ante inserciones y borrados, a diferencia de los enfoques basados en offset que saltan o duplican filas cuando los datos cambian entre peticiones.
Cuando Usar Esto
-
For alternatives, see Complete Guide to GraphQL Federation.
-
Colecciones que crecen con el tiempo y necesitan paginacion estable
-
Clientes que soportan scroll infinito o patrones de “cargar mas”
-
APIs consumidas por Relay, Apollo o cualquier cliente que espere navegacion por cursores
Requisitos Previos
- Un servidor GraphQL (Apollo Server, GraphQL Yoga o similar)
- Una fuente de datos con una columna ordenable y unica (ID, timestamp o cursor)
Solucion
1. Definir los tipos de conexion
// 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. Implementar la codificacion del cursor
// 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. Construir el 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();
return {
edges,
totalCount,
pageInfo: {
hasNextPage: Boolean(first && hasMore),
hasPreviousPage: Boolean(after),
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
},
},
};
4. Consultar la conexion
query GetPosts($first: Int, $after: String) {
posts(pagination: { first: $first, after: $after }) {
edges {
cursor
node {
id
title
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
Pasa el endCursor de la respuesta anterior como after en la siguiente peticion para cargar la proxima pagina.
Como Funciona
- Cursores son tokens opacos que codifican la ultima posicion vista (tipicamente el ID de la fila). Los clientes los tratan como cajas negras.
- Edges emparejan cada node con su cursor, permitiendo navegar desde cualquier punto sin rastrear offsets.
first+afternavega hacia adelante;last+beforehacia atras. El resolver invierte los resultados al paginar hacia atras.take + 1es un truco para verificar si hay mas paginas sin una consulta de conteo separada — si obtienes mas filas de las solicitadas,hasNextPagees true.
Variantes
Fallback con offset
Para fuentes de datos sin soporte de cursores (agregaciones de Elasticsearch, APIs legacy), usa offset pero mantén la forma de conexion para compatibilidad del cliente:
const offset = after ? parseInt(decodeCursor(after), 10) + 1 : 0;
const posts = await db.posts.findMany({ skip: offset, take });
Paginacion keyset con cursores compuestos
Para columnas ordenadas no unicas (como createdAt), usa un cursor compuesto (createdAt, id) para evitar saltar filas con timestamps identicos:
export function encodeCompositeCursor(createdAt: string, id: string): string {
return Buffer.from(`${createdAt}|${id}`).toString('base64');
}
Avanzado: Paginación con Sort Order
Cuando paginas por una columna non-id (e.g., createdAt), codifica el valor de sort en el 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 };
}
El resolver usa un WHERE compuesto para saltar las filas vistas en la página anterior:
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,
});
Esto maneja filas con valores idénticos de createdAt sin saltarlas ni duplicarlas.
Mejores Practicas
- Limita
firstylasta un maximo razonable (50-100) para evitar consultas costosas - Ordena por una columna estable — ordenar por campos no unicos sin desempate causa filas saltadas
- Mantén los cursores opacos — no expongas estructuras internas que los clientes podrian intentar parsear
- Incluye
totalCountsolo cuando el cliente lo necesite; puede ser costoso en tablas grandes
Errores Comunes
- Usar offset como cursor — esto derrocha el proposito de la paginacion por cursores y reintroduce problemas de salto/duplicado
- Olvidar
skip: 1despues de un cursor — sin esto, el primer item de cada pagina repite el ultimo de la pagina anterior - No manejar resultados vacios — retorna un array
edgesvacio conhasNextPage: falseen lugar de lanzar error
FAQ
Q: Debo usar cursor o offset para GraphQL? A: La paginacion por cursores es el estandar para GraphQL porque maneja inserciones y borrados correctamente. Usa offset solo cuando los cursores no sean soportados por la fuente de datos.
Q: Como implemento paginacion bidireccional?
A: Soporta tanto first/after como last/before en tu resolver. El flag hasPreviousPage indica a los clientes si existe una pagina anterior.
Q: Puedo usar Relay connections sin el cliente Relay? A: Si. La especificacion de conexiones funciona con cualquier cliente GraphQL. Apollo Client, urql y graphql-request la soportan.
Q: Que debe codificar el cursor? A: Tipicamente la clave primaria o un compuesto de la columna de orden mas la clave primaria. Evita codificar offsets.
¿Cómo manejo paginación con sort order?
Codifica el valor de la columna de sort más la clave primaria en el cursor (e.g., createdAt|id). El resolver usa un WHERE compuesto: filas donde createdAt < cursor.createdAt, o donde createdAt == cursor.createdAt AND id < cursor.id. Esto evita saltar filas con timestamps idénticos.
¿Debo incluir totalCount en cada respuesta?
Solo cuando el cliente lo necesita. totalCount requiere un query COUNT separado que puede ser costoso en tablas grandes. Hazlo opcional — retornalo solo cuando el cliente solicita el campo. Algunas APIs hacen totalCount nullable y retornan null cuando el count excede un umbral.
¿Cómo limito el page size sin romper la spec?
Setea un máximo para first y last (e.g., 50–100) en el resolver. Si el cliente solicita más, clampea al máximo y retorna la cantidad clampeada. Esto evita queries costosos que escanean grandes porciones de la tabla.
Recursos Relacionados
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 with PostgreSQL
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.