Skip to main content

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.

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.


New files added to the template by this recipe.

FileDescription
app/components/BundleBadge.tsxA badge displayed on bundle product listings.
app/components/BundledVariants.tsxA component that wraps the variants of a bundle product in a single product listing.

Anchor to Step 1: Set up the Shopify Bundles appStep 1: Set up the Shopify Bundles app

  1. Install the Shopify Bundles app in your Shopify admin.

  2. Make sure your store meets the eligibility requirements.

  3. From the Bundles page, create a new bundle.


Anchor to Step 2: Create the BundleBadge componentStep 2: Create the BundleBadge component

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

File

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

Anchor to Step 3: Create a new BundledVariants componentStep 3: Create a new BundledVariants component

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

File

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>
);
}

Add maxVariantPrice to the RecommendedProducts query's product fields.

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

Anchor to Step 5: Show bundled products on the product pageStep 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

@@ -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

Anchor to Step 6: Detect bundles in collection listingsStep 6: Detect bundles in collection listings

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

@@ -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(

Anchor to Step 7: Identify bundles in the cartStep 7: Identify bundles in the cart

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

@@ -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
+ }
+ }
+ }
}
}
}

Anchor to Step 8: Show bundle badges in the cartStep 8: Show bundle badges in the cart

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

@@ -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>

Anchor to Step 9: Update the cart button text for bundlesStep 9: Update the cart button text for bundles

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

@@ -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>
);

Anchor to Step 10: Show bundle badges on product imagesStep 10: Show bundle badges on product images

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

@@ -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>
);
}

Anchor to Step 11: Show bundle badges on product cardsStep 11: Show bundle badges on product cards

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

File

@@ -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>
);
}

Anchor to Step 12: Position bundle badges on imagesStep 12: Position bundle badges on images

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

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

  • 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 to see how orders for bundles appear in your Shopify admin.

Was this page helpful?