Labs ICT
Pro Login

Pagination in GraphQL

Pagination in GraphQL

Pagination is essential for handling large datasets. Instead of loading all records at once, you load them in chunks. GraphQL offers several pagination patterns.

Offset-Based Pagination

The simplest approach uses offset and limit parameters to skip and limit results:

type Query {
  posts(offset: Int, limit: Int): [Post!]!
}

# Schema
type Post {
  id: ID!
  title: String!
  content: String!
}

# Query
query {
  posts(offset: 0, limit: 10) {
    id
    title
  }
}

Offset pagination is simple but has a problem: if data changes between requests, you might miss or duplicate items.

Cursor-Based Pagination

Cursor-based pagination uses a pointer to the last item you fetched. It's more reliable than offset-based pagination.

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

# Query first 10 posts
query {
  posts(first: 10) {
    edges {
      node {
        id
        title
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

# Query next page
query {
  posts(first: 10, after: "cursor-from-previous-query") {
    edges {
      node {
        id
        title
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

This is the Relay specification for pagination. It's more complex but handles edge cases better.

Implementing Cursor Pagination

Here's how to implement cursor-based pagination in your resolvers:

const resolvers = {
  Query: {
    posts: async (parent, args, context) => {
      const { first, after, last, before } = args;
      
      // Build the query
      let query = {};
      if (after) {
        query.createdAt = { $gt: decodeCursor(after) };
      }
      if (before) {
        query.createdAt = { $lt: decodeCursor(before) };
      }
      
      // Fetch one extra item to check if there's a next page
      const limit = first || last;
      const items = await context.db.posts
        .find(query)
        .sort({ createdAt: 1 })
        .limit(limit + 1);
      
      const hasNextPage = items.length > limit;
      const edges = items.slice(0, limit).map(item => ({
        node: item,
        cursor: encodeCursor(item.createdAt),
      }));
      
      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!before,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
        totalCount: await context.db.posts.countDocuments(),
      };
    },
  },
};

The key is to fetch one extra item to determine if there's a next page.

Client-Side Pagination

On the client, use Apollo Client's fetchMore to load additional pages:

import { useQuery, gql } from '@apollo/client';

const GET_POSTS = gql`
  query GetPosts($first: Int, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        node {
          id
          title
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostList() {
  const { data, fetchMore } = useQuery(GET_POSTS, {
    variables: { first: 10 },
  });

  const loadMore = () => {
    fetchMore({
      variables: {
        after: data.posts.pageInfo.endCursor,
      },
    });
  };

  return (
    <div>
      {data?.posts.edges.map(({ node }) => (
        <div key={node.id}>{node.title}</div>
      ))}
      {data?.posts.pageInfo.hasNextPage && (
        <button onClick={loadMore}>Load More</button>
      )}
    </div>
  );
}

Apollo Client automatically merges the new results with the existing cache.