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

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:
- Visually indicating to the user which page of results they are on.
- 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
anddecrementPage
) 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.
Synchronizing with Filters and Search
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
- Initial load: No cursor, fetch first page. Display "Page 1 of N".
- Next clicked: Update URL with
after
, fetch next slice. Increment store page. - Previous clicked: Update URL with
before
, fetch previous slice. Decrement store page. - 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