How to Implement Product Counts in Shopify Storefront API
A hands-on guide for developers building accurate search and collection filters in headless Shopify with Next.js.

As a Shopify/Next.js dev, I know how essential it is to show users how many products match their search or filter—seems basic, right? But as I found out building headless e-commerce, Shopify’s Storefront API has more quirks than you might expect, especially when it comes to product counts on search and collection pages.
Here’s a battle-tested guide (plus my personal “aha” moments) for getting accurate product counts with Shopify’s Storefront API.
Why Show Product Counts?
Clear product counts make filtering and pagination user-friendly. Customers expect to know “how many red shirts are there?,” and, “how many total products?” Getting that number right also helps with page navigation and lets your UI show helpful filter counts like “Red (4), Blue (7).”
But here’s where things get tricky, Shopify Storefront API provides product counts differently depending on which query you use.
Types of Queries: Search vs. Collection
After a lot of trial and error, I discovered there are two main ways to get products from Shopify’s API.
- Search Query: For searching all products, whether the user is using a global search bar or keyword search.
- Collection Query: For browsing products inside a specific collection, like
/collections/jackets
.
Depending on which one you use, you get product counts in totally different ways.
Use Case 1: Search Query – The Straightforward Way ✅
If you’re building a typical search page, the search
query is the right tool for the job. Shopify gives you a totalCount
field, which makes product counts simple and reliable.
GraphQL Example:
query SearchProducts(
$query: String!
$productFilters: [ProductFilter!]
$first: Int
$after: String
) {
search(
query: $query
types: PRODUCT
productFilters: $productFilters
first: $first
after: $after
) {
totalCount
productFilters {
id
label
type
values {
id
label
count
}
}
edges { node { ... } }
pageInfo { hasNextPage endCursor }
}
}
Example TypeScript Usage:
export async function getProducts(params: SearchParams) {
const response = await shopifyFetch({
query: getProductsViaSearchQuery,
variables: {
query: params.query,
productFilters: params.filters,
first: params.first,
after: params.after,
},
});
return {
products: response.body.data.search.edges.map((edge) => edge.node),
pageInfo: response.body.data.search.pageInfo,
filters: response.body.data.search.productFilters,
totalCount: response.body.data.search.totalCount,
};
}
Developer Note: ProductFilter is Your Best Tool
If you’re not already using ProductFilter
with the search
query, I highly recommend switching. I cover this in detail in this article. The short version is: ProductFilter
lets you request exactly the options (like color, size, etc.) you need. For search queries, the API returns an accurate totalCount
and counts for each filter value, so your filtering UI can always tell the user “Red (3), Blue (9),” and so on. This makes everything easier for both you and your users.
Use Case 2: Collection Query – The Slightly Tricky Way
Collection pages use the collection
query. This is where counts can get a little confusing. The response does not include a native totalCount
. That means you’ll have to calculate the full number yourself.
Example Collection Query:
query GetCollectionProducts(
$handle: String!
$productFilters: [ProductFilter!]
$first: Int
$after: String
) {
collection(handle: $handle) {
products(
filters: $productFilters
first: $first
after: $after
) {
filters {
id
label
type
values { id label count }
}
edges { node { ... } }
pageInfo { hasNextPage endCursor }
}
}
}
The Confusing Bit
While building my client’s store, I noticed something odd. When searching for products inside a collection, my product count would sometimes be off by just a few—like 2 or 5 products. The total wasn’t way off, but it was enough to feel weird. It took me some digging to realize that you have to be careful which filter you use for your totals, and you need to deduplicate some responses. This only happened when searching within a collection, never with global search.
How I Calculate the Collection Count
First, always deduplicate filter values. Shopify’s response can have duplicates, which will cause the sum to be slightly too high. Then, for accuracy, don’t just sum the first filter; prefer "product type" if available, and "availability" as a backup.
export function deduplicateFilters(
filters: ShopifyFilterResponse[]
): ShopifyFilterResponse[] {
return filters.map((filter) => {
const uniqueValues = filter.values.filter(
(value, index, array) =>
array.findIndex((v) => v.id === value.id) === index
);
return { ...filter, values: uniqueValues };
});
}
export function calculateTotalFromFilters(
filters: ShopifyFilterResponse[]
): number {
const pt = filters.find(f => f.id === "filter.p.product_type");
if (pt?.values) return pt.values.reduce((sum, v) => sum + v.count, 0);
const av = filters.find(f => f.id === "filter.v.availability");
if (av?.values) return av.values.reduce((sum, v) => sum + v.count, 0);
return 0;
}
Complete Collection Fetch Example
export async function getCollectionProducts(params: CollectionParams) {
const response = await shopifyFetch({
query: getCollectionProductsQuery,
variables: {
handle: params.handle,
productFilters: params.filters,
first: params.first,
after: params.after,
},
});
const rawFilters = response.body.data.collection.products.filters;
const deduplicatedFilters = deduplicateFilters(rawFilters);
const totalCount = calculateTotalFromFilters(deduplicatedFilters);
return {
products: response.body.data.collection.products.edges.map(
(edge) => edge.node
),
pageInfo: response.body.data.collection.products.pageInfo,
filters: deduplicatedFilters,
totalCount,
};
}
Components for Product Counts
Here’s how I display counts in my Next.js projects:
Total Products
export function ActiveFilters({
totalCount,
activeFilters,
onClearAll,
}: ActiveFiltersProps) {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-700 font-medium">
{totalCount} Artikel
</span>
{activeFilters.length > 0 && (
<button onClick={onClearAll} className="text-sm text-blue-600">
Clear All
</button>
)}
</div>
);
}
Filter Sidebar
export function ProductsFilter({
filters,
activeFilters,
onFilterChange,
}: ProductsFilterProps) {
return (
<div className="space-y-6">
{filters.map((filter) => (
<div key={filter.id} className="border-b border-gray-200 pb-4">
<h3 className="font-medium text-gray-900 mb-3">{filter.label}</h3>
<div className="space-y-2">
{filter.values.map((value) => (
<label key={value.id} className="flex items-center">
<input
type="checkbox"
checked={activeFilters.some((f) => f.id === value.id)}
onChange={() => onFilterChange(filter.id, value)}
className="mr-2"
/>
<span className="text-sm">
{value.label}
{" "}
<span className="text-xs text-neutral-500">
({value.count})
</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
);
}
Pagination
export function ShopPagination({
totalCount,
currentPage,
pageSize,
hasNextPage,
onPageChange,
}: ShopPaginationProps) {
const totalPages = Math.ceil(totalCount / pageSize);
return (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700">
{totalCount} {totalCount === 1 ? "Ergebnis" : "Ergebnisse"}
</span>
<div className="flex items-center space-x-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Previous
</button>
<span className="text-sm">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNextPage}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Next
</button>
</div>
</div>
);
}
How It Comes Together
Here’s an example of these components on a collection or search page:
export default async function CollectionPage({
params,
}: { params: { handle: string } }) {
const { products, filters, totalCount, pageInfo } =
await getCollectionProducts({
handle: params.handle,
filters: [], // Parse from URL params
first: 12,
});
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Filter Sidebar */}
<aside className="lg:col-span-1">
<ProductsFilter
filters={filters}
activeFilters={[]} // Parse from URL
onFilterChange={handleFilterChange}
/>
</aside>
{/* Main Content */}
<main className="lg:col-span-3">
<ActiveFilters
totalCount={totalCount}
activeFilters={[]} // Parse from URL
onClearAll={handleClearAll}
/>
{/* Product Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<ShopPagination
totalCount={totalCount}
currentPage={1}
pageSize={12}
hasNextPage={pageInfo.hasNextPage}
onPageChange={handlePageChange}
/>
</main>
</div>
</div>
);
}
Final Advice and Common Pitfalls
- Use the
search
query andProductFilter
when possible. This always gives the most accurate product counts. - When working with collection queries, calculate the total from filter data, use product type or availability filters, and always deduplicate.
- If your totals are off by a small number, it’s probably a deduplication or variant-counting issue.
- Never add up the variant filters for your total product count (like color, size, etc.), because you’ll get numbers that are slightly too high.
- Cursor-based pagination in Shopify means you can’t jump to pages based on index, so always use the count to drive your display, but stick to what the cursor gives you for navigation.
Once you understand these differences, Shopify product counts become easy and can power great user experiences.
If you get stuck, or want to learn more about product filtering strategies, check out my bigger deep-dive: How to add product filters to a Headless Shopify store with Next.js 15 and the Storefront API.