Paginación por cursores con GraphQL Relay Connections
Implementa paginación estilo Relay con cursores en GraphQL usando edges, nodes y pageInfo para recorrido eficiente hacia adelante y atrás
Visión General
La paginación por offset se rompe con datos vivos: inserta una fila al inicio de un feed y todas las páginas siguientes se desplazan, así que los clientes ven duplicados o pierden items. La especificación Relay Connection lo resuelve dándole a cada item un cursor estable — un token opaco que el cliente devuelve para pedir “lo que viene después de esto”. Es la forma estándar de paginación para APIs GraphQL, esperada por Relay y soportada por todos los clientes importantes.
Esta receta implementa la spec completa: tipos Connection/Edge/PageInfo, codificación de cursores en base64, un resolver que pagina hacia adelante y hacia atrás, y la variante que importa en producción — cursores compuestos para columnas de orden no únicas.
Cuándo Usar Esto
- Colecciones que crecen con el tiempo y necesitan paginación estable
- Clientes que soportan scroll infinito o patrones de “cargar más”
- APIs consumidas por Relay, Apollo o cualquier cliente que espere navegación por cursores
Para un endpoint REST con paginación por offset, mira la paginación por cursores en PostgreSQL — misma técnica, otro transporte. No uses connections para listas estáticas pequeñas (un dropdown de países): la ceremonia no vale la pena ahí.
Requisitos Previos
- Un servidor GraphQL (Apollo Server, GraphQL Yoga o similar)
- Una fuente de datos con una columna ordenable y única (ID, timestamp o cursor)
Solución
1. Definir los tipos de conexión
// 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 codificación 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();
// 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. Consultar la conexión
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 petición para cargar la próxima página.
Cómo Funciona
- Cursores son tokens opacos que codifican la última posición vista (típicamente 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 atrás. El resolver invierte los resultados al paginar hacia atrás.take + 1es un truco para verificar si hay más páginas sin una consulta de conteo separada — si obtienes más 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 conexión para compatibilidad del cliente:
const offset = after ? parseInt(decodeCursor(after), 10) + 1 : 0;
const posts = await db.posts.findMany({ skip: offset, take });
Paginación keyset con cursores compuestos
Para columnas ordenadas no únicas (como createdAt), usa un cursor compuesto (createdAt, id) para evitar saltar filas con timestamps idénticos:
export function encodeCompositeCursor(createdAt: string, id: string): string {
return Buffer.from(`${createdAt}|${id}`).toString('base64');
}
Avanzado: Paginación con Orden Personalizado
Cuando paginas por una columna que no es el ID (e.g., createdAt), codifica el valor de orden 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 Prácticas
- Limita
firstylasta un máximo razonable (50–100) para evitar consultas costosas. - Ordena por una columna estable — ordenar por campos no únicos sin desempate causa filas saltadas.
- Mantén los cursores opacos — no expongas estructuras internas que los clientes podrían intentar parsear.
- Incluye
totalCountsolo cuando el cliente lo necesite; puede ser costoso en tablas grandes. - Indexa la columna de orden — la paginación por cursores sobre una columna sin índice es un full table scan en cada página.
Errores Comunes
- Usar offset como cursor — esto anula el propósito de la paginación por cursores y reintroduce problemas de salto/duplicado.
- Olvidar
skip: 1después de un cursor — sin esto, el primer item de cada página repite el último de la página anterior. - No manejar resultados vacíos — devuelve un array
edgesvacío conhasNextPage: falseen lugar de lanzar error. - Flags de pageInfo incorrectos con
last/before— al paginar hacia atrás,hasPreviousPagesale dehasMoreyhasNextPagees true porque llegaste desde una página posterior.
Solución de Problemas
- El primer item de cada página duplica el último de la anterior: te faltó
skip: 1(o el filtro>equivalente) después del cursor — la consulta está trayendo de nuevo la fila del cursor. - Todas las páginas muestran
hasNextPage: false: estás pidiendotakefilas en vez detake + 1, así que nunca hay una fila de más para detectar la siguiente página. - Filas saltadas al ordenar por
createdAt: dos filas comparten el mismo timestamp y el cursor solo codifica esa columna. Cambia a un cursor compuesto(createdAt, id). - El cursor
afterlanza error o devuelve la página 1: el cliente envió un cursor malformado o viejo (base64 que decodifica basura, o un ID que ya no existe). Decodifica a la defensiva y trata un cursor inválido como “sin cursor” o como un 400 — tú eliges, pero no dejes que el resolver explote. - El cliente pasó
firstylasta la vez: la spec dice elegir uno. Rechaza la combinación o dale precedencia afirst— las sorpresas silenciosas aquí producen páginas confusas.
Ver También
- Patrón GraphQL Connection Pagination — el patrón arquitectónico detrás de esta receta.
- Paginación por cursores en PostgreSQL — la misma técnica keyset aplicada a un endpoint REST.
- Especificación Relay Connection — la referencia normativa de la forma implementada aquí.
Código companion: servidor de paginación Relay ejecutable: el schema, los helpers de cursor y el resolver de esta receta conectados a un Apollo Server con datos de prueba.
Preguntas frecuentes
¿Debo usar cursor u offset para GraphQL?
La paginación por cursores es el estándar para GraphQL porque maneja inserciones y borrados correctamente. Usa offset solo cuando los cursores no sean soportados por la fuente de datos.
¿Cómo implemento paginación bidireccional?
Soporta tanto first/after como last/before en tu resolver. El flag hasPreviousPage indica a los clientes si existe una página anterior.
¿Puedo usar Relay connections sin el cliente Relay?
Sí. La especificación de conexiones funciona con cualquier cliente GraphQL. Apollo Client, urql y graphql-request la soportan.
¿Qué debe codificar el cursor?
Típicamente la clave primaria o un compuesto de la columna de orden más la clave primaria. Evita codificar offsets.
¿Cómo manejo la paginación con orden personalizado?
Codifica el valor de la columna de orden 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 — devuélvelo solo cuando el cliente solicita el campo.
¿Cómo limito el page size sin romper la spec?
Define un máximo para first y last (e.g., 50–100) en el resolver. Si el cliente solicita más, ajústalo al máximo y devuelve la cantidad ajustada. Esto evita queries costosos que escanean grandes porciones de la tabla.
Recursos Relacionados
Construye una API GraphQL con Apollo Server y TypeScript
Cómo construir una API GraphQL lista para producción usando Apollo Server, TypeScript y DataLoader para resolver el problema de consultas N+1
RecipePaginación por Cursor en PostgreSQL (Keyset vs OFFSET)
Implementa paginación eficiente por cursor para datasets grandes en PostgreSQL, evitando la degradación de rendimiento de OFFSET con paginación keyset indexada y ordenamiento estable
PatternPatron de Resolver Batch en GraphQL
Resuelve campos anidados de GraphQL en una sola peticion batch para eliminar consultas N+1 y reducir la carga de la base de datos.
RecipeBatch y cache de consultas con GraphQL DataLoader
Usa DataLoader para agrupar peticiones de carga individuales en llamadas batch a la base de datos, resolviendo el problema N+1 en resolvers GraphQL
PatternPatron GraphQL Connection Pagination
Implementa paginacion basada en cursores estilo Relay con edges, nodes y pageInfo para queries de listas estables en GraphQL.