How to Implement Cursor-Based Pagination in a Headless Shopify Storefront Built With Nextjs

A Developer's Guide to Shopify Storefront API Pagination: Overcoming Cursor Limitations for Modern Headless Ecommerce

·Matija Žiberna·
How to Implement Cursor-Based Pagination in a Headless Shopify Storefront Built With Nextjs

When building a custom Shopify storefront with the Storefront API, I needed familiar-looking pagination. However, Shopify only offers cursor-based pagination, not traditional page-number-based navigation.

This article explains the key constraints of Shopify’s API, the major pitfalls, and a practical solution that keeps the user experience familiar. Every code fragment is documented with its specific purpose and reasoning.


The Main Challenge: No Skipping to Arbitrary Pages

Shopify uses cursor-based pagination. This means you cannot jump to a random page by number. Instead, each page is accessed relative to a cursor value pointing before or after the current data.

This differs from offset-based pagination, where you could fetch specific page slices like "items 21-40" directly. With cursors, you can only go forward or backward a single page at a time.

Pitfall: You cannot support a “jump to page” or “Go to page N” feature. The API does not allow accessing the nth page directly.


Solution Overview

The user interface shows page numbers—calculated for presentation only—but all real navigation is handled by cursors according to Shopify’s constraints. When filters or sort order are changed, pagination resets. The backend always fetches based on the cursor and page direction.

Core Ideas

  • Only the cursor controls which products the user sees. Page numbers are not sent to the backend.
  • The frontend tracks the display page number for user feedback only.
  • "Page X of Y" is calculated from Shopify's totalCount and your chosen page size.
  • Pagination resets to first page on any new filter or search.
  • URLs drive the real state; sharing a link or refreshing always renders the correct data, even if the displayed page number starts at 1.

Calculating Display Page Numbers

// For UI: How many pages should appear in the control
export function calculateTotalPages(totalCount: number, pageSize: number): number {
  return Math.ceil(totalCount / pageSize);
}
// Example: 51 items with pageSize=20 => 3 pages

Why?
Shopify’s API provides totalCount. Divide by your pageSize to display "Page X of Y". This does not affect navigation or data fetching.


Presentation-Only Pagination Store

The pagination store is a client-only UI state. It is unrelated to backend data or URL. This keeps the interface responsive and easy to reason about.

Important:
This is not a source of truth for data fetches. Products are always retrieved using cursors from the URL. The store is only for display.

Downside

If a user shares the URL for page 3, or reloads the page, the UI will always show "Page 1" (the display state resets). However, the products shown are always correct for the current cursor. This edge case is generally insignificant.

Store Implementation

"use client";
import { create } from "zustand";

/**
 * Presentation-only store. Not used for backend logic.
 * Used just to show user which page number they're on.
 */
interface PaginationStore {
  currentPage: number;       // Display only
  currentContext: string;    // e.g. collection, query, or "all-products"
  currentFilters: string;    // Serialized active filters

  setCurrentPage: (page: number) => void;
  incrementPage: () => void;
  decrementPage: () => void;
  resetToFirstPage: (context: string, filters?: string) => void;
}

export const usePaginationStore = create<PaginationStore>((set, get) => ({
  currentPage: 1,
  currentContext: "",
  currentFilters: "",

  setCurrentPage: (page: number) => {
    set({ currentPage: Math.max(1, page) });
  },

  incrementPage: () => {
    const { currentPage } = get();
    set({ currentPage: currentPage + 1 });
  },

  decrementPage: () => {
    const { currentPage } = get();
    set({ currentPage: Math.max(1, currentPage - 1) });
  },

  resetToFirstPage: (context: string, filters = "") => {
    const { currentContext, currentFilters } = get();
    if (currentContext !== context || currentFilters !== filters) {
      set({
        currentPage: 1,
        currentContext: context,
        currentFilters: filters,
      });
    }
  },
}));

GraphQL Query Construction

All pagination is handled by after or before cursors, plus a direction (first for forward, last for backward).

query SearchProducts(
  $query: String!,
  $productFilters: [ProductFilter!],
  $sortKey: SearchSortKeys,
  $first: Int,
  $last: Int,
  $after: String,
  $before: String
) {
  search(
    query: $query,
    types: PRODUCT,
    productFilters: $productFilters,
    sortKey: $sortKey,
    first: $first,
    last: $last,
    after: $after,
    before: $before
  ) {
    totalCount    // Used for display, not navigation
    pageInfo {
      hasNextPage    // Enable/disable Next
      hasPreviousPage// Enable/disable Prev
      endCursor      // For "Next"
      startCursor    // For "Prev"
    }
    edges {
      node { ... }
    }
  }
}

Only one of after or before is ever used in a request. Forward: first and after. Backward: last and before.


Mapping URL Parameters to Cursor Direction

To know how to build each GraphQL query, interpret the URL’s cursor params.

// Figures out direction for paginating
export function parsePaginationParams(searchParams: { after?: string | string[]; before?: string | string[]; }) {
  const after = Array.isArray(searchParams.after) ? searchParams.after[0] : searchParams.after;
  const before = Array.isArray(searchParams.before) ? searchParams.before[0] : searchParams.before;

  if (before) {
    return { before, direction: "backward" };
  }
  if (after) {
    return { after, direction: "forward" };
  }
  return { direction: "forward" }; // First page
}

Data Fetching Logic

The backend fetches products using parsed pagination params.

export async function getProducts({
  productFilters = [],
  sortKey,
  first,
  last,
  after,
  before,
}: {
  productFilters?: ProductFilter[];
  sortKey?: string;
  first?: number;
  last?: number;
  after?: string;
  before?: string;
}) {
  // Build variables based on direction
  const queryVariables = {
    productFilters,
    sortKey: sortKey || "RELEVANCE",
    first,
    last,
    after,
    before,
  };

  const res = await shopifyFetch({
    query: getProductsViaSearchQuery,
    variables: queryVariables,
  });

  return {
    products: reshapeProducts(res.body.data.search.edges.map(edge => edge.node)),
    pageInfo: res.body.data.search.pageInfo,
    totalCount: res.body.data.search.totalCount,
    filters: res.body.data.search.productFilters,
  };
}

Server-Side Rendering and Passing Data

The main product or collection page is implemented as a React Server Component in the Next.js App Router. This is important because, in Next.js, server components can safely fetch data, interact directly with databases or external APIs, and run server-side code. All execution happens on the server, not in the user’s browser.

In this context, parameters like pagination cursors, filters, or sort values live in the URL’s search parameters (query string). The server component receives these search parameters and parses them to decide what data to fetch.

Notice the use of the custom helper function parsePaginationParams. This function decodes the search parameters (after, before, filters, etc.) into a direction and cursor that can be mapped to GraphQL variables. The after and before values specifically are set by the client-side pagination component, which updates the URL based on user actions (such as clicking “Next” or “Previous”). Because the Next.js App Router surfaces these changes to the server component, every navigation or filter change results in a new server-side fetch using updated cursor parameters.

export default async function CollectionsPage(props: { searchParams?: ... }) {
  const searchParams = await props.searchParams;

  const parsed = parseAllFilterFromSearchParams(searchParams || {});
  const paginationParams = parsePaginationParams({
    after: searchParams?.after,
    before: searchParams?.before,
  });

  const { pageSize } = getPaginationConfig();

  const graphqlParams = paginationParams.direction === "backward"
    ? { last: pageSize, before: paginationParams.before }
    : { first: pageSize, after: paginationParams.after };

  const result = parsed.collection 
    ? await getProductsInCollection({
        ...graphqlParams,
        collection: parsed.collection,
        productFilters: parsed.filters,
        sortKey: parsed.sort.sortKey,
        reverse: parsed.sort.reverse,
      })
    : await getProducts({
        ...graphqlParams,
        productFilters: parsed.filters,
        sortKey: parsed.sort.sortKey,
        reverse: parsed.sort.reverse,
      });

  // Pass down to the display section/UI
  return (
    <ProductListingSection
      products={result.products}
      pageInfo={result.pageInfo}
      totalCount={result.totalCount}
    />
  );
}

Pagination Controls (Navigation UI)

The pagination component is a client-side Nextjs component responsible for two key things:

  1. Visually indicating to the user which page of results they are on.
  2. Updating the URL with the correct cursor parameters when the user clicks "Next" or "Previous". This URL change signals to the Next.js App Router that new data must be fetched on the server.

It is important to remember that all navigation is actually triggered by the URL change. When you update the URL, Next.js reruns the server component with the new parameters and fetches the correct page of results. The client-side store in this component is used only to update the page number display in the UI. It does not control data fetching.

Whenever the context (such as collection or search query) or filters change, the page number is reset to 1 for clarity and consistency in the UI.

export default function ShopPagination({
  hasNextPage,
  hasPrevPage,
  endCursor,
  startCursor,
  totalCount,
  pageSize,
  context,
  filters,
}: PaginationControlsProps) {
  const { currentPage, incrementPage, decrementPage, resetToFirstPage } = usePaginationStore();
  const totalPages = calculateTotalPages(totalCount, pageSize);

  // Make sure the displayed page is reset whenever the context or filters change
  React.useEffect(() => {
    resetToFirstPage(context, filters);
  }, [context, filters, resetToFirstPage]);

  // This function updates the URL with the correct cursor and then updates the UI.
  // Updating the URL tells Next.js to refetch data on the server.
  const navigateToPage = (direction: "prev" | "next") => {
    const params = new URLSearchParams(searchParams.toString());
    params.delete("after");
    params.delete("before");
    if (direction === "next" && hasNextPage && endCursor) {
      params.set("after", endCursor);
      router.push(createUrl(pathname, params));
      setTimeout(() => incrementPage(), 100);
    } else if (direction === "prev" && hasPrevPage && startCursor) {
      params.set("before", startCursor);
      router.push(createUrl(pathname, params));
      setTimeout(() => decrementPage(), 100);
    }
  };

  return (
    <div className="pagination-controls">
      <Button onClick={() => navigateToPage("prev")} disabled={!hasPrevPage}>
        Previous
      </Button>
      <div>
        Page {currentPage} of {totalPages}
      </div>
      <Button onClick={() => navigateToPage("next")} disabled={!hasNextPage}>
        Next
      </Button>
    </div>
  );
}

Key details to note:

  • The client-side store (with incrementPage and decrementPage) is for page display only, not for fetching logic.
  • The authoritative state for pagination lives in the URL via the cursor parameters (after, before).
  • Every change to the cursor in the URL triggers a fresh server fetch, keeping data and UI reliably in sync.

As already mentione, changing filters always clears all pagination state.

export function useProductFilters() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const navigateWithParams = async (newParams: URLSearchParams) => {
    newParams.delete("page");
    newParams.delete("cursor");
    newParams.delete("after");
    newParams.delete("before"); // Always remove cursor on filter change
    const newUrl = `${pathname}?${newParams.toString()}`;
    try {
      router.push(newUrl);
      await invalidateProductCache(pathname, newParams.toString());
      router.refresh();
    } catch (error) {
      router.push(newUrl);
    }
  };

  const toggleFilter = async (filter: ProductFilter) => {
    const newParams = toggleFilterInParams(searchParams, filter);
    await navigateWithParams(newParams);
  };

  return { toggleFilter };
}

Complete Flow Example

  1. Initial load: No cursor, fetch first page. Display "Page 1 of N".
  2. Next clicked: Update URL with after, fetch next slice. Increment store page.
  3. Previous clicked: Update URL with before, fetch previous slice. Decrement store page.
  4. Filter applied: Remove all cursors, fetch fresh from start. Display "Page 1 of M".

Summary & Best Practices

  • Never attempt arbitrary page navigation. The API is strictly cursor-based.
  • Use presentation-only store for the "current page" display.
  • Backend fetch always relies on cursor parameters from the URL, never page numbers.
  • Reset pagination on filter or sort change.
  • Expect UI page number to reset to 1 on refresh or direct link sharing, but content and paging always match the URL.

This structure stays within Shopify’s constraints and provides reliable, understandable pagination for both users and developers. Adjust the pieces as needed, always remembering that only cursor-based navigation is possible with this API.

Thanks, Matija

7

Frequently Asked Questions

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in