Savvi Studio

Cursor API Integration

Purpose: Integrate cursors with REST and GraphQL APIs
Last Updated: 2024-11-26

Overview

This guide covers patterns for exposing cursor-based pagination through API endpoints.

For complete code examples, see Cursor Pagination Examples.

REST API Patterns

Basic Endpoint

See: Example 3 - REST API Endpoint

// GET /api/nodes?cursor=...&limit=50
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const cursor = searchParams.get('cursor') ?? undefined;
  const limit = Math.min(
    parseInt(searchParams.get('limit') ?? '50'),
    100  // Max limit
  );
  
  const result = await paginateNodes(cursor, limit);
  
  return Response.json({
    data: result.nodes,
    pagination: {
      cursor: result.cursor,
      hasMore: result.hasMore,
      limit
    }
  });
}

Response Format

{
  "data": [
    { "id": "123", "name": "Node 1" },
    { "id": "124", "name": "Node 2" }
  ],
  "pagination": {
    "cursor": "eyJsYXN0SWQiOjEyNCwibGltaXQiOjUwfQ==",
    "hasMore": true,
    "limit": 50
  }
}

Error Handling

export async function GET(request: Request) {
  try {
    const cursor = searchParams.get('cursor') ?? undefined;
    const result = await paginateNodes(cursor);
    return Response.json({ data: result.nodes, ...result });
  } catch (error) {
    if (error.message === 'Invalid cursor') {
      return Response.json(
        { error: 'Invalid cursor, please start from the beginning' },
        { status: 400 }
      );
    }
    throw error;
  }
}

GraphQL Patterns

Relay Connection Pattern

See: Example 4 - GraphQL Resolver

const resolvers = {
  Query: {
    nodes: async (_parent, { first, after }, { client }) => {
      const result = await paginateNodes(client, after);
      
      return {
        edges: result.nodes.map(node => ({
          node,
          cursor: encodeCursor({
            lastId: node.id,
            limit: first,
            direction: 'forward',
            timestamp: Date.now(),
            version: 1
          })
        })),
        pageInfo: {
          hasNextPage: result.hasMore,
          hasPreviousPage: false,
          startCursor: result.nodes[0] 
            ? encodeCursor({ lastId: result.nodes[0].id })
            : null,
          endCursor: result.cursor
        }
      };
    }
  }
};

GraphQL Schema

type Query {
  nodes(
    first: Int = 50
    after: String
  ): NodeConnection!
}

type NodeConnection {
  edges: [NodeEdge!]!
  pageInfo: PageInfo!
}

type NodeEdge {
  node: Node!
  cursor: String!
}

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

Client Usage

REST Client

async function fetchAllNodes() {
  let cursor: string | null = null;
  let allNodes: Node[] = [];
  
  do {
    const url = new URL('/api/nodes', baseUrl);
    if (cursor) url.searchParams.set('cursor', cursor);
    
    const response = await fetch(url);
    const { data, pagination } = await response.json();
    
    allNodes.push(...data);
    cursor = pagination.hasMore ? pagination.cursor : null;
  } while (cursor);
  
  return allNodes;
}

GraphQL Client

const query = gql`
  query GetNodes($after: String) {
    nodes(first: 50, after: $after) {
      edges {
        node { id name }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

async function fetchAllNodes() {
  let after: string | null = null;
  let allNodes: Node[] = [];
  
  do {
    const { data } = await client.query({
      query,
      variables: { after }
    });
    
    allNodes.push(...data.nodes.edges.map(e => e.node));
    after = data.nodes.pageInfo.hasNextPage 
      ? data.nodes.pageInfo.endCursor 
      : null;
  } while (after);
  
  return allNodes;
}

Infinite Scroll

React Example

function useInfiniteNodes() {
  const [nodes, setNodes] = useState<Node[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  
  const loadMore = async () => {
    if (loading || !hasMore) return;
    
    setLoading(true);
    try {
      const url = `/api/nodes${cursor ? `?cursor=${cursor}` : ''}`;
      const response = await fetch(url);
      const { data, pagination } = await response.json();
      
      setNodes(prev => [...prev, ...data]);
      setCursor(pagination.cursor);
      setHasMore(pagination.hasMore);
    } finally {
      setLoading(false);
    }
  };
  
  return { nodes, loading, hasMore, loadMore };
}

Best Practices

✅ Do

  • Validate cursor before using
  • Set maximum page size limits
  • Include pagination metadata in responses
  • Handle invalid cursors gracefully
  • Document cursor format for clients
  • Use consistent response structure

❌ Don't

  • Expose internal cursor structure
  • Allow unbounded page sizes
  • Assume cursor is always valid
  • Return errors without fallback
  • Skip cursor expiration checks

Next steps: See Examples for complete implementation code.