Skip to main content

Pagination

Fanfare uses cursor-based pagination for list endpoints, providing efficient traversal of large datasets.

Cursor-Based Pagination

Cursor-based pagination uses opaque cursors instead of page numbers, offering several advantages:
  • Consistent results: No skipped or duplicated items when data changes
  • Efficient: Doesn’t require counting total items
  • Bidirectional: Navigate forward and backward through results

Request Parameters

Query Parameters

ParameterTypeDescription
firstnumberNumber of items to return (max 100, default varies by endpoint)
afterstringCursor for forward pagination (return items after this cursor)
beforestringCursor for backward pagination (return items before this cursor)
lastnumberNumber of items when paginating backward
sortBystringField to sort by (endpoint-specific)
sortDirstringSort direction: asc or desc

Example Request

GET /api/v1/consumers?first=20&after=eyJpZCI6ImNvbnNfMDFIWFlaIn0= HTTP/1.1
Host: admin.fanfare.io
Authorization: Bearer sk_live_xxxxxxxxxxxx

Response Format

Paginated Response Structure

{
  "data": [
    {
      "id": "cons_01HXYZ123456789",
      "email": "[email protected]",
      "createdAt": "2024-11-01T10:00:00Z"
    },
    {
      "id": "cons_01HXYZ123456790",
      "email": "[email protected]",
      "createdAt": "2024-11-01T10:01:00Z"
    }
  ],
  "pageInfo": {
    "startCursor": "eyJpZCI6ImNvbnNfMDFIWFlaIn0=",
    "endCursor": "eyJpZCI6ImNvbnNfMDFIWFlaMn0="
  }
}

Response Fields

FieldTypeDescription
dataarrayArray of items for the current page
pageInfo.startCursorstringCursor for the first item in the results
pageInfo.endCursorstringCursor for the last item in the results

Pagination Examples

Forward Pagination

Fetch the first page:
GET /api/v1/consumers?first=20 HTTP/1.1
Fetch the next page using endCursor:
GET /api/v1/consumers?first=20&after=eyJpZCI6ImNvbnNfMDFIWFlaMn0= HTTP/1.1

Backward Pagination

Fetch the previous page using startCursor:
GET /api/v1/consumers?last=20&before=eyJpZCI6ImNvbnNfMDFIWFlaIn0= HTTP/1.1
Combine pagination with search:
GET /api/v1/consumers?first=20&search=john HTTP/1.1
Note: Search requires at least 2 characters.

With Sorting

Combine pagination with custom sorting:
GET /api/v1/consumers?first=20&sortBy=createdAt&sortDir=desc HTTP/1.1

Implementation Guide

JavaScript/TypeScript

interface PageInfo {
  startCursor: string | null;
  endCursor: string | null;
}

interface PaginatedResponse<T> {
  data: T[];
  pageInfo: PageInfo;
}

async function fetchAllPages<T>(endpoint: string, options: RequestInit): Promise<T[]> {
  const allItems: T[] = [];
  let cursor: string | null = null;

  do {
    const url = cursor ? `${endpoint}?first=100&after=${cursor}` : `${endpoint}?first=100`;

    const response = await fetch(url, options);
    const result: PaginatedResponse<T> = await response.json();

    allItems.push(...result.data);
    cursor = result.pageInfo.endCursor;
  } while (cursor);

  return allItems;
}

React Hook Example

import { useState, useCallback } from "react";

interface UsePaginationOptions {
  pageSize?: number;
}

function usePagination<T>(endpoint: string, options: UsePaginationOptions = {}) {
  const { pageSize = 20 } = options;
  const [data, setData] = useState<T[]>([]);
  const [pageInfo, setPageInfo] = useState<PageInfo | null>(null);
  const [loading, setLoading] = useState(false);

  const fetchPage = useCallback(
    async (cursor?: string, direction: "forward" | "backward" = "forward") => {
      setLoading(true);
      try {
        const params = new URLSearchParams();

        if (direction === "forward") {
          params.set("first", String(pageSize));
          if (cursor) params.set("after", cursor);
        } else {
          params.set("last", String(pageSize));
          if (cursor) params.set("before", cursor);
        }

        const response = await fetch(`${endpoint}?${params}`);
        const result: PaginatedResponse<T> = await response.json();

        setData(result.data);
        setPageInfo(result.pageInfo);
      } finally {
        setLoading(false);
      }
    },
    [endpoint, pageSize]
  );

  const nextPage = useCallback(() => {
    if (pageInfo?.endCursor) {
      fetchPage(pageInfo.endCursor, "forward");
    }
  }, [pageInfo, fetchPage]);

  const prevPage = useCallback(() => {
    if (pageInfo?.startCursor) {
      fetchPage(pageInfo.startCursor, "backward");
    }
  }, [pageInfo, fetchPage]);

  return {
    data,
    pageInfo,
    loading,
    fetchPage,
    nextPage,
    prevPage,
    hasNextPage: !!pageInfo?.endCursor,
    hasPrevPage: !!pageInfo?.startCursor,
  };
}

Infinite Scroll Example

function useInfiniteScroll<T>(endpoint: string) {
  const [items, setItems] = useState<T[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    try {
      const params = new URLSearchParams({ first: "20" });
      if (cursor) params.set("after", cursor);

      const response = await fetch(`${endpoint}?${params}`);
      const result: PaginatedResponse<T> = await response.json();

      setItems((prev) => [...prev, ...result.data]);
      setCursor(result.pageInfo.endCursor);
      setHasMore(!!result.pageInfo.endCursor);
    } finally {
      setLoading(false);
    }
  }, [endpoint, cursor, hasMore, loading]);

  return { items, loadMore, hasMore, loading };
}

Sortable Fields

Different endpoints support different sortable fields:

Consumers

FieldDescription
createdAtConsumer creation timestamp (default)
emailConsumer email address
fullNameConsumer full name
lastOrderAtLast order timestamp
totalOrderValueLifetime order value

Experiences

FieldDescription
createdAtExperience creation timestamp (default)
nameExperience name
openAtExperience open timestamp

Audiences

FieldDescription
createdAtAudience creation timestamp (default)
nameAudience name
lastRefreshedAtLast refresh timestamp

Best Practices

1. Use Appropriate Page Sizes

  • Interactive UIs: 20-50 items for quick loading
  • Background sync: 100 items for efficiency
  • Never exceed: Maximum of 100 items per request

2. Handle Empty Results

const result = await fetchPage();
if (result.data.length === 0) {
  // Handle empty state
}

3. Preserve Cursor on Refresh

When implementing “pull to refresh”, preserve the current position:
const currentCursor = pageInfo?.startCursor;
await fetchPage(); // Refresh from beginning
// Optionally navigate back to previous position

4. Combine with Filters Carefully

When combining pagination with filters, cursors may become invalid if filter criteria change:
// When filters change, reset pagination
useEffect(() => {
  setCursor(null);
  fetchPage();
}, [filters]);

Cursor Format

Cursors are Base64-encoded JSON objects. While the format is not guaranteed to remain stable, they typically contain:
{
  "id": "cons_01HXYZ123456789",
  "sortValue": "2024-11-01T10:00:00Z"
}
Important: Treat cursors as opaque strings. Do not decode or construct them manually.

Limitations

  • Maximum page size: 100 items
  • Cursors expire after 24 hours of inactivity
  • Cursor format may change between API versions
  • Some filtered views may not support all sort options