---
title: Bundles in Hydrogen
description: Display product bundles on your Hydrogen storefront.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/headless/legacy/hydrogen/cookbook/bundles
  md: >-
    https://shopify.dev/docs/storefronts/headless/legacy/hydrogen/cookbook/bundles.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).

# Bundles in Hydrogen

This recipe adds special styling for product bundles on your Hydrogen storefront. Customers will see badges and relevant cover images for bundles when they're viewing product and collection pages.

In this recipe you'll make the following changes:

1. Set up the Shopify Bundles app in your Shopify admin and create a new product bundle.
2. Update the GraphQL fragments to query for bundles to identify bundled products.
3. Update the product and collection templates to display badges on product listings, update the copy for the cart buttons, and display bundle-specific information on product and collection pages.
4. Update the cart line item template to display the bundle badge as needed.

***

## Requirements

To use product bundles, you need to install a bundles app in your Shopify admin. In this recipe, we'll use the [Shopify Bundles app](https://apps.shopify.com/shopify-bundles).

***

## Ingredients

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

| File | Description |
| - | - |
| [app/components/BundleBadge.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/bundles/ingredients/templates/skeleton/app/components/BundleBadge.tsx) | A badge displayed on bundle product listings. |
| [app/components/BundledVariants.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/bundles/ingredients/templates/skeleton/app/components/BundledVariants.tsx) | A component that wraps the variants of a bundle product in a single product listing. |

***

## Step 1: Set up the Shopify Bundles app

1. Install the [Shopify Bundles app](https://apps.shopify.com/shopify-bundles) in your Shopify admin.

2. Make sure your store meets the [eligibility requirements](https://help.shopify.com/en/manual/products/bundles/eligibility-and-considerations).

3. From the [**Bundles**](https://admin.shopify.com/apps/shopify-bundles/app) page, [create a new bundle](https://help.shopify.com/en/manual/products/bundles/shopify-bundles).

***

## Step 2: Create the Bundle​Badge component

Create a new BundleBadge component to be displayed on bundle product listings.

#### File: [BundleBadge.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/bundles/ingredients/templates/skeleton/app/components/BundleBadge.tsx)

## File

```tsx
export function BundleBadge() {
  return (
    <div
      style={{
        position: 'absolute',
        padding: '.5rem .75rem',
        fontSize: '11px',
        backgroundColor: '#10804c',
        color: 'white',
        top: '1rem',
        right: '1rem',
      }}
    >
      BUNDLE
    </div>
  );
}
```

***

## Step 3: Create a new Bundled​Variants component

Create a new `BundledVariants` component that wraps the variants of a bundle product in a single product listing.

#### File: [BundledVariants.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/bundles/ingredients/templates/skeleton/app/components/BundledVariants.tsx)

## File

```tsx
import {Link} from 'react-router';
import {Image} from '@shopify/hydrogen';
import type {
  ProductVariantComponent,
  Image as ShopifyImage,
} from '@shopify/hydrogen/storefront-api-types';


export function BundledVariants({
  variants,
}: {
  variants: ProductVariantComponent[];
}) {
  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        paddingTop: '1rem',
      }}
    >
      {variants
        ?.map(({productVariant: bundledVariant, quantity}) => {
          const url = `/products/${bundledVariant.product.handle}`;
          return (
            <Link
              style={{
                display: 'flex',
                flexDirection: 'row',
                marginBottom: '.5rem',
              }}
              to={url}
              key={bundledVariant.id}
            >
              <Image
                alt={bundledVariant.title}
                aspectRatio="1/1"
                height={60}
                loading="lazy"
                width={60}
                data={bundledVariant.image as ShopifyImage}
              />
              <div
                style={{
                  display: 'flex',
                  flexDirection: 'column',
                  paddingLeft: '1rem',
                }}
              >
                <small>
                  {bundledVariant.product.title}
                  {bundledVariant.title !== 'Default Title'
                    ? `- ${bundledVariant.title}`
                    : null}
                </small>
                <small>Qty: {quantity}</small>
              </div>
            </Link>
          );
        })
        .filter(Boolean)}
    </div>
  );
}
```

***

## Step 4: Query bundle pricing for recommended products

Add `maxVariantPrice` to the `RecommendedProducts` query's product fields.

#### 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/bundles/patches/_index.tsx.243e26.patch))

```diff
@@ -151,6 +151,10 @@ const RECOMMENDED_PRODUCTS_QUERY = `#graphql
         amount
         currencyCode
       }
+      maxVariantPrice {
+        amount
+        currencyCode
+      }
     }
     featuredImage {
       id
```

***

## Step 5: Show bundled products on the product page

1. Add the `requiresComponents` field to the `Product` fragment, which is used to identify bundled products.
2. Pass the `isBundle` flag to the `ProductImage` component.

#### 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/bundles/patches/products.$handle.tsx.6f2e82.patch))

## File

```diff
@@ -15,6 +15,8 @@ import {ProductPrice} from '~/components/ProductPrice';
 import {ProductImage} from '~/components/ProductImage';
 import {ProductForm} from '~/components/ProductForm';
 import {redirectIfHandleIsLocalized} from '~/lib/redirect';
+import type {ProductVariantComponent} from '@shopify/hydrogen/storefront-api-types';
+import {BundledVariants} from '~/components/BundledVariants';
 
 export const meta: Route.MetaFunction = ({data}) => {
   return [
@@ -104,9 +106,12 @@ export default function Product() {
 
   const {title, descriptionHtml} = product;
 
+  const isBundle = Boolean(product.isBundle?.requiresComponents);
+  const bundledVariants = isBundle ? product.isBundle?.components.nodes : null;
+
   return (
     <div className="product">
-      <ProductImage image={selectedVariant?.image} />
+      <ProductImage image={selectedVariant?.image} isBundle={isBundle} />
       <div className="product-main">
         <h1>{title}</h1>
         <ProductPrice
@@ -117,6 +122,7 @@ export default function Product() {
         <ProductForm
           productOptions={productOptions}
           selectedVariant={selectedVariant}
+          isBundle={isBundle}
         />
         <br />
         <br />
@@ -126,6 +132,14 @@ export default function Product() {
         <br />
         <div dangerouslySetInnerHTML={{__html: descriptionHtml}} />
         <br />
+        {isBundle && (
+          <div>
+            <h4>Bundled Products</h4>
+            <BundledVariants
+              variants={bundledVariants as ProductVariantComponent[]}
+            />
+          </div>
+        )}
       </div>
       <Analytics.ProductView
         data={{
@@ -180,6 +194,28 @@ const PRODUCT_VARIANT_FRAGMENT = `#graphql
       amount
       currencyCode
     }
+    requiresComponents
+    components(first: 10) {
+      nodes {
+        productVariant {
+          id
+          title
+          product {
+            handle
+          }
+        }
+        quantity
+      }
+    }
+    groupedBy(first: 10) {
+      nodes {
+        id
+        title
+        product {
+          handle
+        }
+      }
+    }
   }
 ` as const;
 
@@ -216,6 +252,25 @@ const PRODUCT_FRAGMENT = `#graphql
     adjacentVariants (selectedOptions: $selectedOptions) {
       ...ProductVariant
     }
+    # Check if the product is a bundle
+    isBundle: selectedOrFirstAvailableVariant(ignoreUnknownOptions: true, selectedOptions: { name: "", value: ""}) {
+      ...on ProductVariant {
+        requiresComponents
+        components(first: 100) {
+           nodes {
+              productVariant {
+                ...ProductVariant
+              }
+              quantity
+           }
+        }
+        groupedBy(first: 100) {
+          nodes {
+              id
+            }
+          }
+        }
+    }
     seo {
       description
       title
```

***

## Step 6: Detect bundles in collection listings

Like the previous step, use the `requiresComponents` field to detect if the product item is a bundle.

#### 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/bundles/patches/collections.$handle.tsx.f062a9.patch))

```diff
@@ -120,10 +120,16 @@ const PRODUCT_ITEM_FRAGMENT = `#graphql
         ...MoneyProductItem
       }
     }
+    # Check if the product is a bundle
+    isBundle: selectedOrFirstAvailableVariant(ignoreUnknownOptions: true, selectedOptions: { name: "", value: ""}) {
+      ...on ProductVariant {
+        requiresComponents
+      }
+    }
   }
 ` as const;
 
-// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection
+// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/collection
 const COLLECTION_QUERY = `#graphql
   ${PRODUCT_ITEM_FRAGMENT}
   query Collection(
```

***

## Step 7: Identify bundles in the cart

Use the `requiresComponents` field to determine if a cart line item is a bundle.

#### File: [fragments.ts](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/lib/fragments.ts) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/bundles/patches/fragments.ts.026109.patch))

```diff
@@ -52,6 +52,19 @@ export const CART_QUERY_FRAGMENT = `#graphql
           name
           value
         }
+        requiresComponents
+        components(first: 10) {
+          nodes {
+            productVariant {
+              id
+              title
+              product {
+                handle
+              }
+            }
+            quantity
+          }
+        }
       }
     }
   }
@@ -102,6 +115,28 @@ export const CART_QUERY_FRAGMENT = `#graphql
           name
           value
         }
+        requiresComponents
+        components(first: 10) {
+          nodes {
+            productVariant {
+              id
+              title
+              product {
+                handle
+              }
+            }
+            quantity
+          }
+        }
+        groupedBy(first: 10) {
+          nodes {
+            id
+            title
+            product {
+              handle
+            }
+          }
+        }
       }
     }
   }
```

***

## Step 8: Show bundle badges in the cart

If a product is a bundle, show the `BundleBadge` component in the cart line item.

#### File: [CartLineItem.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/components/CartLineItem.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/bundles/patches/CartLineItem.tsx.2c9a50.patch))

```diff
@@ -6,6 +6,7 @@ import {Link} from 'react-router';
 import {ProductPrice} from './ProductPrice';
 import {useAside} from './Aside';
 import type {CartApiQueryFragment} from 'storefrontapi.generated';
+import {BundleBadge} from '~/components/BundleBadge';
 
 type CartLine = OptimisticCartLine<CartApiQueryFragment>;
 
@@ -24,6 +25,7 @@ export function CartLineItem({
   const {product, title, image, selectedOptions} = merchandise;
   const lineItemUrl = useVariantUrl(product.handle, selectedOptions);
   const {close} = useAside();
+  const isBundle = Boolean(line.merchandise.requiresComponents);
 
   return (
     <li key={id} className="cart-line">
@@ -38,8 +40,9 @@ export function CartLineItem({
         />
       )}
 
-      <div>
+      <div style={{display: 'flex', flexDirection: 'column', width: '100%'}}>
         <Link
+          style={{position: 'relative'}}
           prefetch="intent"
           to={lineItemUrl}
           onClick={() => {
@@ -48,9 +51,10 @@ export function CartLineItem({
             }
           }}
         >
-          <p>
+          <p style={{maxWidth: '60%'}}>
             <strong>{product.title}</strong>
           </p>
+          {isBundle ? <BundleBadge /> : null}
         </Link>
         <ProductPrice price={line?.cost?.totalAmount} />
         <ul>
```

***

## Step 9: Update the cart button text for bundles

If a product is a bundle, update the text of the product button.

#### 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/bundles/patches/ProductForm.tsx.649d3b.patch))

```diff
@@ -11,9 +11,11 @@ import type {ProductFragment} from 'storefrontapi.generated';
 export function ProductForm({
   productOptions,
   selectedVariant,
+  isBundle,
 }: {
   productOptions: MappedProductOptions[];
   selectedVariant: ProductFragment['selectedOrFirstAvailableVariant'];
+  isBundle: boolean;
 }) {
   const navigate = useNavigate();
   const {open} = useAside();
@@ -118,7 +120,11 @@ export function ProductForm({
             : []
         }
       >
-        {selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
+        {selectedVariant?.availableForSale
+          ? isBundle
+            ? 'Add bundle to cart'
+            : 'Add to cart'
+          : 'Sold out'}
       </AddToCartButton>
     </div>
   );
```

***

## Step 10: Show bundle badges on product images

If a product is a bundle, show the `BundleBadge` component in the `ProductImage` component.

#### 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/bundles/patches/ProductImage.tsx.42c3b6.patch))

```diff
@@ -1,10 +1,13 @@
 import type {ProductVariantFragment} from 'storefrontapi.generated';
 import {Image} from '@shopify/hydrogen';
+import {BundleBadge} from './BundleBadge';
 
 export function ProductImage({
   image,
+  isBundle = false,
 }: {
   image: ProductVariantFragment['image'];
+  isBundle: boolean;
 }) {
   if (!image) {
     return <div className="product-image" />;
@@ -18,6 +21,7 @@ export function ProductImage({
         key={image.id}
         sizes="(min-width: 45em) 50vw, 100vw"
       />
+      {isBundle ? <BundleBadge /> : null}
     </div>
   );
 }
```

***

## Step 11: Show bundle badges on product cards

If a product is a bundle, show the `BundleBadge` component in the `ProductItem` component.

#### 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/bundles/patches/ProductItem.tsx.d3223b.patch))

## File

```diff
@@ -1,24 +1,19 @@
 import {Link} from 'react-router';
 import {Image, Money} from '@shopify/hydrogen';
-import type {
-  ProductItemFragment,
-  CollectionItemFragment,
-  RecommendedProductFragment,
-} from 'storefrontapi.generated';
+import type {ProductItemFragment} from 'storefrontapi.generated';
 import {useVariantUrl} from '~/lib/variants';
+import {BundleBadge} from '~/components/BundleBadge';
 
 export function ProductItem({
   product,
   loading,
 }: {
-  product:
-    | CollectionItemFragment
-    | ProductItemFragment
-    | RecommendedProductFragment;
+  product: ProductItemFragment;
   loading?: 'eager' | 'lazy';
 }) {
   const variantUrl = useVariantUrl(product.handle);
-  const image = product.featuredImage;
+  const isBundle = product?.isBundle?.requiresComponents;
+
   return (
     <Link
       className="product-item"
@@ -26,19 +21,22 @@ export function ProductItem({
       prefetch="intent"
       to={variantUrl}
     >
-      {image && (
-        <Image
-          alt={image.altText || product.title}
-          aspectRatio="1/1"
-          data={image}
-          loading={loading}
-          sizes="(min-width: 45em) 400px, 100vw"
-        />
-      )}
-      <h4>{product.title}</h4>
-      <small>
-        <Money data={product.priceRange.minVariantPrice} />
-      </small>
+      <div style={{position: 'relative'}}>
+        {product.featuredImage && (
+          <Image
+            alt={product.featuredImage.altText || product.title}
+            aspectRatio="1/1"
+            data={product.featuredImage}
+            loading={loading}
+            sizes="(min-width: 45em) 400px, 100vw"
+          />
+        )}
+        <h4>{product.title}</h4>
+        <small>
+          <Money data={product.priceRange.minVariantPrice} />
+        </small>
+        {isBundle && <BundleBadge />}
+      </div>
     </Link>
   );
 }
```

***

## Step 12: Position bundle badges on images

Make sure the bundle badge is positioned relative to the product image.

#### 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/bundles/patches/app.css.881a99.patch))

```diff
@@ -435,6 +435,10 @@ button.reset:hover:not(:has(> *)) {
   margin-top: 0;
 }
 
+.product-image {
+  position: relative;
+}
+
 .product-image img {
   height: auto;
   width: 100%;
```

***

## Next steps

* Test your implementation by going to your store and adding a bundle to the cart. Make sure that the bundle's badge appears on the product page and in the cart.
* (Optional) [Place a test order](https://help.shopify.com/en/manual/checkout-settings/test-orders) to see how orders for bundles appear in your Shopify admin.

***
