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.

·Matija Žiberna·
How to Implement Product Counts in Shopify Storefront API

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 and ProductFilter 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.

14

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