StackPractices
intermediate Por Mathias Paulenko

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

flowchart diagram: Cliente: first 20, after null
  1. 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.
  2. Edges emparejan cada node con su cursor, permitiendo navegar desde cualquier punto sin rastrear offsets.
  3. first + after navega hacia adelante; last + before hacia atrás. El resolver invierte los resultados al paginar hacia atrás.
  4. take + 1 es un truco para verificar si hay más páginas sin una consulta de conteo separada — si obtienes más filas de las solicitadas, hasNextPage es 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 first y last a 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 totalCount solo 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: 1 despué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 edges vacío con hasNextPage: false en lugar de lanzar error.
  • Flags de pageInfo incorrectos con last/before — al paginar hacia atrás, hasPreviousPage sale de hasMore y hasNextPage es 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 pidiendo take filas en vez de take + 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 after lanza 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ó first y last a la vez: la spec dice elegir uno. Rechaza la combinación o dale precedencia a first — las sorpresas silenciosas aquí producen páginas confusas.

Ver También

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.