---
title: Combined Listings in Hydrogen
description: Handle combined listings on product pages and in search results.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/headless/legacy/hydrogen/cookbook/combined-listings
  md: >-
    https://shopify.dev/docs/storefronts/headless/legacy/hydrogen/cookbook/combined-listings.md
api_name: hydrogen
---

Legacy Hydrogen

These pages refer to Hydrogen versions 2026-04 and earlier. For versions 2026-10 and later, refer to the latest [Hydrogen docs](https://shopify.dev/docs/storefronts/hydrogen). If you're not using the latest version of Hydrogen, then we recommend that you migrate your storefront. To learn more, refer to [Migrate to the latest version of Hydrogen](https://shopify.dev/docs/storefronts/hydrogen/migrate).

# Combined Listings in Hydrogen

This recipe lets you more precisely display and manage [combined listings](https://help.shopify.com/en/manual/products/combined-listings-app) on product pages and in search results for your Hydrogen storefront. A combined listing groups separate products together into a single product listing using a shared option like color or size. Each product appears as a variant but can have its own title, description, URL, and images. In this recipe, you'll make the following changes:

1. Set up the Combined Listings app in your Shopify admin and group relevant products together as combined listings.
2. Configure how combined listings will be handled on your storefront.
3. Update the `ProductForm` component to hide the **Add to cart** button for the parent products of combined listings.
4. Update the `ProductImage` component to support images from product variants and the product itself.
5. Show a range of prices for combined listings in `ProductItem`.

**Note:**

This recipe is compatible with React Router 7.9.x and uses consolidated imports from 'react-router' instead of separate '@shopify/remix-oxygen' and '@remix-run/react' packages.

***

## Requirements

* Your store must be on either a [Shopify Plus](https://www.shopify.com/plus) or enterprise plan.
* Your store must have the [Combined Listings app](https://admin.shopify.com/apps/combined-listings) installed.

***

## Ingredients

*New files added to the template by this recipe.*

| File | Description |
| - | - |
| [app/lib/combined-listings.ts](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/ingredients/templates/skeleton/app/lib/combined-listings.ts) | The `combined-listings.ts` file contains utilities and settings for handling combined listings. |

***

## Step 1: Set up the Combined Listings app

1. Install the [Combined Listings app](https://admin.shopify.com/apps/combined-listings).

2. [Create combined listing products in your store](https://help.shopify.com/en/manual/products/combined-listings-app#creating-a-combined-listing).

3. Add tags to the parent products of combined listings to indicate that they're part of a combined listing (for example `combined`).

***

## Step 2: Configure combined listings behavior

You can customize how the parent products of combined listings are retrieved and displayed.

To make this process easier, we included a configuration object in the `combined-listings.ts` file that you can edit to customize according to your preferences.

```ts
// Edit these values to customize the combined listings behaviors
export const combinedListingsSettings = {
  // If true, loading the product page will redirect to the first variant
  redirectToFirstVariant: false,
  // The tag that indicates a combined listing
  combinedListingTag: 'combined',
  // If true, combined listings will not be shown in the product list
  hideCombinedListingsFromProductList: true,
};
```

***

## Step 3: Add combined listings utilities

Create a new `combined-listings.ts` file that contains utilities and settings for handling combined listings.

#### File: [combined-listings.ts](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/ingredients/templates/skeleton/app/lib/combined-listings.ts)

## File

```ts
// Edit these values to customize combined listings' behavior
export const combinedListingsSettings = {
  // If true, loading the product page will redirect to the first variant
  redirectToFirstVariant: false,
  // The tag that indicates a combined listing
  combinedListingTag: 'combined',
  // If true, combined listings will not be shown in the product list
  hideCombinedListingsFromProductList: true,
};


export const maybeFilterOutCombinedListingsQuery =
  combinedListingsSettings.hideCombinedListingsFromProductList
    ? `NOT tag:${combinedListingsSettings.combinedListingTag}`
    : '';


interface ProductWithTags {
  tags: string[];
}


function isProductWithTags(u: unknown): u is ProductWithTags {
  const maybe = u as ProductWithTags;
  return (
    u != null &&
    typeof u === 'object' &&
    'tags' in maybe &&
    Array.isArray(maybe.tags)
  );
}


export function isCombinedListing(product: unknown) {
  return (
    isProductWithTags(product) &&
    product.tags.includes(combinedListingsSettings.combinedListingTag)
  );
}
```

***

## Step 4: Hide the cart button for combined listing parent products

1. Update the `ProductForm` component to hide the **Add to cart** button for the parent products of combined listings and for variants' selected state.
2. Update the `Link` component to not replace the current URL when the product is a combined listing parent product.

#### File: [ProductForm.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/components/ProductForm.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/patches/ProductForm.tsx.649d3b.patch))

## File

```diff
@@ -11,9 +11,11 @@ import type {ProductFragment} from 'storefrontapi.generated';
 export function ProductForm({
   productOptions,
   selectedVariant,
+  combinedListing,
 }: {
   productOptions: MappedProductOptions[];
   selectedVariant: ProductFragment['selectedOrFirstAvailableVariant'];
+  combinedListing: boolean;
 }) {
   const navigate = useNavigate();
   const {open} = useAside();
@@ -50,12 +52,13 @@ export function ProductForm({
                       key={option.name + name}
                       prefetch="intent"
                       preventScrollReset
-                      replace
+                      replace={!combinedListing}
                       to={`/products/${handle}?${variantUriQuery}`}
                       style={{
-                        border: selected
-                          ? '1px solid black'
-                          : '1px solid transparent',
+                        border:
+                          selected && !combinedListing
+                            ? '1px solid black'
+                            : '1px solid transparent',
                         opacity: available ? 1 : 0.3,
                       }}
                     >
@@ -76,9 +79,10 @@ export function ProductForm({
                       }`}
                       key={option.name + name}
                       style={{
-                        border: selected
-                          ? '1px solid black'
-                          : '1px solid transparent',
+                        border:
+                          selected && !combinedListing
+                            ? '1px solid black'
+                            : '1px solid transparent',
                         opacity: available ? 1 : 0.3,
                       }}
                       disabled={!exists}
@@ -101,25 +105,27 @@ export function ProductForm({
           </div>
         );
       })}
-      <AddToCartButton
-        disabled={!selectedVariant || !selectedVariant.availableForSale}
-        onClick={() => {
-          open('cart');
-        }}
-        lines={
-          selectedVariant
-            ? [
-                {
-                  merchandiseId: selectedVariant.id,
-                  quantity: 1,
-                  selectedVariant,
-                },
-              ]
-            : []
-        }
-      >
-        {selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
-      </AddToCartButton>
+      {!combinedListing && (
+        <AddToCartButton
+          disabled={!selectedVariant || !selectedVariant.availableForSale}
+          onClick={() => {
+            open('cart');
+          }}
+          lines={
+            selectedVariant
+              ? [
+                  {
+                    merchandiseId: selectedVariant.id,
+                    quantity: 1,
+                    selectedVariant,
+                  },
+                ]
+              : []
+          }
+        >
+          {selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
+        </AddToCartButton>
+      )}
     </div>
   );
 }
```

***

## Step 5: Support product and variant images

Update the `ProductImage` component to support images from both product variants and the product itself.

#### File: [ProductImage.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/components/ProductImage.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/patches/ProductImage.tsx.42c3b6.patch))

```diff
@@ -1,10 +1,13 @@
-import type {ProductVariantFragment} from 'storefrontapi.generated';
+import type {
+  ProductVariantFragment,
+  ProductFragment,
+} from 'storefrontapi.generated';
 import {Image} from '@shopify/hydrogen';
 
 export function ProductImage({
   image,
 }: {
-  image: ProductVariantFragment['image'];
+  image: ProductVariantFragment['image'] | ProductFragment['featuredImage'];
 }) {
   if (!image) {
     return <div className="product-image" />;
```

***

## Step 6: Show a range of prices for combined listings in Product​Item

Update `ProductItem.tsx` to show a range of prices for the combined listing parent product instead of the variant price.

#### File: [ProductItem.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/components/ProductItem.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/patches/ProductItem.tsx.d3223b.patch))

```diff
@@ -6,6 +6,7 @@ import type {
   RecommendedProductFragment,
 } from 'storefrontapi.generated';
 import {useVariantUrl} from '~/lib/variants';
+import {isCombinedListing} from '../lib/combined-listings';
 
 export function ProductItem({
   product,
@@ -36,9 +37,17 @@ export function ProductItem({
         />
       )}
       <h4>{product.title}</h4>
-      <small>
-        <Money data={product.priceRange.minVariantPrice} />
-      </small>
+      {isCombinedListing(product) ? (
+        <small className="combined-listing-price">
+          <Money data={product.priceRange.minVariantPrice} />
+          <span>–</span>
+          <Money data={product.priceRange.maxVariantPrice} />
+        </small>
+      ) : (
+        <small>
+          <Money data={product.priceRange.minVariantPrice} />
+        </small>
+      )}
     </Link>
   );
 }
```

***

## Step 7: (Optional) Redirect to the first variant

If you want to redirect automatically to the first variant of a combined listing when the parent handle is selected, add a redirect utility that's called whenever the parent handle is requested.

#### File: [redirect.ts](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/lib/redirect.ts) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/patches/redirect.ts.6d9851.patch))

```diff
@@ -1,4 +1,6 @@
 import {redirect} from 'react-router';
+import type {ProductFragment} from 'storefrontapi.generated';
+import {isCombinedListing} from './combined-listings';
 
 export function redirectIfHandleIsLocalized(
   request: Request,
@@ -21,3 +23,23 @@ export function redirectIfHandleIsLocalized(
     throw redirect(url.toString());
   }
 }
+
+export function redirectIfCombinedListing(
+  request: Request,
+  product: ProductFragment,
+) {
+  const url = new URL(request.url);
+  let shouldRedirect = false;
+
+  if (isCombinedListing(product)) {
+    url.pathname = url.pathname.replace(
+      product.handle,
+      product.selectedOrFirstAvailableVariant?.product.handle ?? '',
+    );
+    shouldRedirect = true;
+  }
+
+  if (shouldRedirect) {
+    throw redirect(url.toString());
+  }
+}
```

***

## Step 8: Filter combined listings from the all products page

Add filtering to the all products catalog page to exclude combined listing parent products.

#### File: [collections.all.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/routes/collections.all.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/patches/collections.all.tsx.38a40d.patch))

```diff
@@ -1,11 +1,13 @@
 import type {Route} from './+types/collections.all';
-import {
-  useLoaderData,
-} from 'react-router';
+import {useLoaderData} from 'react-router';
 import {getPaginationVariables, Image, Money} from '@shopify/hydrogen';
 import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
 import {ProductItem} from '~/components/ProductItem';
 import type {CollectionItemFragment} from 'storefrontapi.generated';
+import {
+  combinedListingsSettings,
+  maybeFilterOutCombinedListingsQuery,
+} from '../lib/combined-listings';
 
 export const meta: Route.MetaFunction = () => {
   return [{title: `Hydrogen | Products`}];
@@ -33,7 +35,12 @@ async function loadCriticalData({context, request}: Route.LoaderArgs) {
 
   const [{products}] = await Promise.all([
     storefront.query(CATALOG_QUERY, {
-      variables: {...paginationVariables},
+      variables: {
+        ...paginationVariables,
+        query: combinedListingsSettings.hideCombinedListingsFromProductList
+          ? maybeFilterOutCombinedListingsQuery
+          : '',
+      },
     }),
     // Add other queries here, so that they are loaded in parallel
   ]);
@@ -80,6 +87,7 @@ const COLLECTION_ITEM_FRAGMENT = `#graphql
     id
     handle
     title
+    tags
     featuredImage {
       id
       altText
@@ -107,8 +115,9 @@ const CATALOG_QUERY = `#graphql
     $last: Int
     $startCursor: String
     $endCursor: String
+    $query: String
   ) @inContext(country: $country, language: $language) {
-    products(first: $first, last: $last, before: $startCursor, after: $endCursor) {
+    products(first: $first, last: $last, before: $startCursor, after: $endCursor, query: $query) {
       nodes {
         ...CollectionItem
       }
```

***

## Step 9: Filter recommended products

1. Add the `tags` property to the items returned by the product query.
2. (Optional) Add the filtering query to the product query to exclude combined listings.

#### File: [\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/routes/_index.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/patches/_index.tsx.243e26.patch))

## File

```diff
@@ -11,6 +11,7 @@ import type {
   RecommendedProductsQuery,
 } from 'storefrontapi.generated';
 import {ProductItem} from '~/components/ProductItem';
+import {maybeFilterOutCombinedListingsQuery} from '~/lib/combined-listings';
 
 export const meta: Route.MetaFunction = () => {
   return [{title: 'Hydrogen | Home'}];
@@ -48,7 +49,11 @@ async function loadCriticalData({context}: Route.LoaderArgs) {
  */
 function loadDeferredData({context}: Route.LoaderArgs) {
   const recommendedProducts = context.storefront
-    .query(RECOMMENDED_PRODUCTS_QUERY)
+    .query(RECOMMENDED_PRODUCTS_QUERY, {
+      variables: {
+        query: maybeFilterOutCombinedListingsQuery,
+      },
+    })
     .catch((error: Error) => {
       // Log query errors, but don't throw them so the page can still render
       console.error(error);
@@ -104,11 +109,9 @@ function RecommendedProducts({
         <Await resolve={products}>
           {(response) => (
             <div className="recommended-products-grid">
-              {response
-                ? response.products.nodes.map((product) => (
-                    <ProductItem key={product.id} product={product} />
-                  ))
-                : null}
+              {response?.products.nodes.map((product) => (
+                <ProductItem key={product.id} product={product} />
+              ))}
             </div>
           )}
         </Await>
@@ -151,7 +154,12 @@ const RECOMMENDED_PRODUCTS_QUERY = `#graphql
         amount
         currencyCode
       }
+      maxVariantPrice {
+        amount
+        currencyCode
+      }
     }
+    tags
     featuredImage {
       id
       url
@@ -160,9 +168,9 @@ const RECOMMENDED_PRODUCTS_QUERY = `#graphql
       height
     }
   }
-  query RecommendedProducts ($country: CountryCode, $language: LanguageCode)
+  query RecommendedProducts ($country: CountryCode, $language: LanguageCode, $query: String)
     @inContext(country: $country, language: $language) {
-    products(first: 4, sortKey: UPDATED_AT, reverse: true) {
+    products(first: 4, sortKey: UPDATED_AT, reverse: true, query: $query) {
       nodes {
         ...RecommendedProduct
       }
```

***

## Step 10: (Optional) Filter out combined listings from collections pages

Since it's not possible to directly apply query filters when retrieving collection products, you can manually filter out combined listings after they're retrieved based on their tags.

#### File: [collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/routes/collections.$handle.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/patches/collections.$handle.tsx.f062a9.patch))

## File

```diff
@@ -5,6 +5,10 @@ import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
 import {redirectIfHandleIsLocalized} from '~/lib/redirect';
 import {ProductItem} from '~/components/ProductItem';
 import type {ProductItemFragment} from 'storefrontapi.generated';
+import {
+  combinedListingsSettings,
+  isCombinedListing,
+} from '~/lib/combined-listings';
 
 export const meta: Route.MetaFunction = ({data}) => {
   return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}];
@@ -68,12 +72,25 @@ function loadDeferredData({context}: Route.LoaderArgs) {
 export default function Collection() {
   const {collection} = useLoaderData<typeof loader>();
 
+  // Manually filter out combined listings from the collection products, because filtering
+  // would not work here.
+  const filteredCollectionProducts = {
+    ...collection.products,
+    nodes: collection.products.nodes.filter(
+      (product) =>
+        !(
+          combinedListingsSettings.hideCombinedListingsFromProductList &&
+          isCombinedListing(product)
+        ),
+    ),
+  };
+
   return (
     <div className="collection">
       <h1>{collection.title}</h1>
       <p className="collection-description">{collection.description}</p>
       <PaginatedResourceSection<ProductItemFragment>
-        connection={collection.products}
+        connection={filteredCollectionProducts}
         resourcesClassName="products-grid"
       >
         {({node: product, index}) => (
@@ -105,6 +122,7 @@ const PRODUCT_ITEM_FRAGMENT = `#graphql
     id
     handle
     title
+    tags
     featuredImage {
       id
       altText
@@ -144,7 +162,7 @@ const COLLECTION_QUERY = `#graphql
         first: $first,
         last: $last,
         before: $startCursor,
-        after: $endCursor
+        after: $endCursor,
       ) {
         nodes {
           ...ProductItem
```

***

## Step 11: Show price ranges on product pages

1. Display a range of prices for combined listings instead of the variant price.
2. Show the featured image of the combined listing parent product instead of the variant image.
3. (Optional) Redirect to the first variant of a combined listing when the handle is requested.

#### File: [products.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/routes/products.$handle.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/patches/products.$handle.tsx.6f2e82.patch))

## File

```diff
@@ -14,7 +14,14 @@ import {
 import {ProductPrice} from '~/components/ProductPrice';
 import {ProductImage} from '~/components/ProductImage';
 import {ProductForm} from '~/components/ProductForm';
-import {redirectIfHandleIsLocalized} from '~/lib/redirect';
+import {
+  redirectIfCombinedListing,
+  redirectIfHandleIsLocalized,
+} from '~/lib/redirect';
+import {
+  isCombinedListing,
+  combinedListingsSettings,
+} from '../lib/combined-listings';
 
 export const meta: Route.MetaFunction = ({data}) => {
   return [
@@ -66,6 +73,10 @@ async function loadCriticalData({
   // The API handle might be localized, so redirect to the localized handle
   redirectIfHandleIsLocalized(request, {handle, data: product});
 
+  if (combinedListingsSettings.redirectToFirstVariant) {
+    redirectIfCombinedListing(request, product);
+  }
+
   return {
     product,
   };
@@ -85,6 +96,7 @@ function loadDeferredData({context, params}: Route.LoaderArgs) {
 
 export default function Product() {
   const {product} = useLoaderData<typeof loader>();
+  const combinedListing = isCombinedListing(product);
 
   // Optimistically selects a variant with given available variant information
   const selectedVariant = useOptimisticVariant(
@@ -94,7 +106,9 @@ export default function Product() {
 
   // Sets the search param to the selected variant without navigation
   // only when no search params are set in the url
-  useSelectedOptionInUrlParam(selectedVariant.selectedOptions);
+  useSelectedOptionInUrlParam(
+    combinedListing ? [] : selectedVariant.selectedOptions,
+  );
 
   // Get the product options array
   const productOptions = getProductOptions({
@@ -102,21 +116,41 @@ export default function Product() {
     selectedOrFirstAvailableVariant: selectedVariant,
   });
 
-  const {title, descriptionHtml} = product;
+  const {descriptionHtml, title} = product;
+
+  const productImage = combinedListing
+    ? (product.featuredImage ?? selectedVariant?.image)
+    : selectedVariant?.image;
 
   return (
     <div className="product">
-      <ProductImage image={selectedVariant?.image} />
+      <ProductImage image={productImage} />
       <div className="product-main">
         <h1>{title}</h1>
-        <ProductPrice
-          price={selectedVariant?.price}
-          compareAtPrice={selectedVariant?.compareAtPrice}
-        />
+        {combinedListing ? (
+          <div>
+            <div style={{display: 'flex', gap: '10px'}}>
+              <span style={{display: 'flex', gap: '5px'}}>
+                From
+                <ProductPrice price={product.priceRange.minVariantPrice} />
+              </span>
+              <span style={{display: 'flex', gap: '5px'}}>
+                To
+                <ProductPrice price={product.priceRange.maxVariantPrice} />
+              </span>
+            </div>
+          </div>
+        ) : (
+          <ProductPrice
+            price={selectedVariant?.price}
+            compareAtPrice={selectedVariant?.compareAtPrice}
+          />
+        )}
         <br />
         <ProductForm
+          combinedListing={combinedListing}
           productOptions={productOptions}
-          selectedVariant={selectedVariant}
+          selectedVariant={combinedListing ? undefined : selectedVariant}
         />
         <br />
         <br />
@@ -193,6 +227,22 @@ const PRODUCT_FRAGMENT = `#graphql
     description
     encodedVariantExistence
     encodedVariantAvailability
+    tags
+    featuredImage {
+      id
+      url
+      altText
+    }
+    priceRange {
+      minVariantPrice {
+        amount
+        currencyCode
+      }
+      maxVariantPrice {
+        amount
+        currencyCode
+      }
+    }
     options {
       name
       optionValues {
```

***

## Step 12: Style the price range display

Add a class to the product item to show a range of prices for combined listings.

#### File: [app.css](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/styles/app.css) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/combined-listings/patches/app.css.881a99.patch))

```diff
@@ -418,6 +418,11 @@ button.reset:hover:not(:has(> *)) {
   width: 100%;
 }
 
+.product-item .combined-listing-price {
+  display: flex;
+  grid-gap: 0.5rem;
+}
+
 /*
 * --------------------------------------------------
 * routes/products.$handle.tsx
```

***

## Next steps

* Test your implementation by going to your store and searching for a combined listing. Make sure that the combined listing's details appear in the search results and on the product page.
* (Optional) [Place a test order](https://help.shopify.com/en/manual/checkout-settings/test-orders) to see how orders for combined listings appear in your Shopify admin.

***
