'Bundle and Save' PDP Block Driven by a Product-List Metafield
One product-list metafield, one card per product, and on every card a working add-to-cart form of its own — a merchandiser-curated set sold on the product page where the shopper already is, with a carousel that only appears when there is more than one thing to scroll.
Shopify's recommendation rails are algorithmic; a priced routine of specific products has to be authored. On LUS Brands' product page, one product-list metafield drives a "Bundle and Save" block that renders a card per product with the theme's own price snippet and a real, independently submittable product form, upgrading to a Swiper carousel only when the set has more than one item.
The Problem
Shopify's built-in product recommendations are computed, not authored. They answer "what do people buy with this?" from order history and product data, and they answer it client-side, after the page has painted. What they can't express is "this cleanser, this conditioner and this styler are the routine that goes with this product" — a specific, ordered set with a supporting line of copy under each item.
Curl care sells as a routine. LUS Brands needed that routine on the product page at the moment of intent: real prices, the savings the shopper would actually get, and a one-tap add — not a link out to a bundle page that starts the decision over. And the mapping of which products belong with which had to be the merchandising team's to change, per product, without a deployment.
The Constraint
A product page already has a product form. Dawn's <product-form> element owns the submit, the error rendering and the cart-drawer refresh, and it expects a form carrying a hidden variant id input. Putting several more of those on one page means every form needs a unique id, and every one of them has to behave like the main form does — same add route, same drawer — or the shopper gets two different kinds of "added."
The card had to be tappable as a whole and carry a button. A full-card link overlay and a submit button are two interactive layers occupying the same rectangle, and the wrong stacking order swallows one of them.
The block lives inside the rebuilt product section as one of its block types, so it's a snippet rendered with product and block — it has no section settings of its own. Pricing had to match the rest of the theme: the savings badge, the currency-code toggle and unit pricing all live in one snippet, and reprinting money with filters inline would have drifted from it the first time that snippet changed. And the relationship data had to stay in the admin — the set for each product arrived from the client as a mapping document, not as a code decision.
What We Built
bundle_and_save.liquid reads product.metafields.custom.bundle_and_save.value, a product-list metafield. If the list is blank the snippet emits nothing — no heading, no wrapper. If it holds more than one product, a slider flag is set and the container takes on Swiper's classes and a DOM id composed from the section and block: BundleAndSaveSlider-<section.id>-<block.id>.
Each product in the list becomes a card. The image is a link to the product carrying an aria-label of the title, rendered through image_tag with a four-step widths ladder and a sizes hint that tells the browser the card is a small tile. Under it: the title as a link, the vendor's review-widget mount for that product, and the price row.
The price row is the theme's own price snippet, called with product: bundle_product and variant: bundle_variant where the variant is the product's selected_or_first_available_variant. Passing the variant explicitly is what makes the money correct per item: the snippet compares that variant's compare_at_price to its price, adds the price--on-sale class and the savings badge when there's a saving, honors the storefront's currency_code_enabled setting, and renders unit pricing when the variant has a measurement. show_badges: false suppresses the "Sale" and "Sold out" tags, so the card shows a saving without a label competing with it.
Then an optional supporting line. custom.bundle_sub_text is read from the recommended product's metafields, not the host product's, so a product's one-line reason travels with it into every set it appears in. A "view set" link follows, and then the form.
The form is real. A <product-form data-hide-errors="false" data-section-id="{{ section.id }}"> wraps {% form 'product', bundle_product %} with an id built from the block id and the loop index — bundle-and-save-form-<block.id>-<index> — so five cards are five distinct forms. Inside is the hidden id input, class product-variant-id, holding the chosen variant, and a submit button whose label is a block setting. Dawn's product-form.js takes it from there: it posts to the cart add route with the drawer's section ids attached, then renders the returned drawer HTML. A bundle card adds through exactly the path the main buy button uses.
Two rules of CSS keep the card working. The image link's ::before pseudo-element is stretched to the full size of the cards container — which, for a single-product set, is the card — at z-index: 1, so anywhere the shopper taps goes to the product; the .product-form sits at position: relative; z-index: 2, so the add button stays above that overlay. In slider mode the overlay drops to z-index: -1, beneath the content and inert — a full-width link at the top of the stack would eat the swipe gesture, and each card's image and title keep their own links anyway.
When the slider is on, the cards get swiper-slide, the track gets swiper-wrapper, and a pagination element [data-bundle-and-save-pagination] renders after the track. An inline script constructs new Swiper(slider, …) against the block's DOM id with one slide per view, a small gap, and clickable pagination bullets. Scoped styles keyed to that id turn the bullets into pills.
The block schema exposes three text settings: heading, view_set_label and add_to_cart_label. Everything else comes from the metafield.
Why This Way
Curating the set in a metafield puts the relationship where the people who own it can edit it. The client authored the product-to-product mapping as a document; that document became product fields, and the block reads them. A new set, or a changed one, is an admin edit on that product. No template, no deploy.
Reusing the price snippet was the second call. Bundle cards inherit the savings badge, the currency handling and unit pricing automatically, and when that snippet changes the cards change with it. Reusing Dawn's <product-form> was the third, for the same reason: one add path, one error surface, one drawer refresh, all already tested by the main buy button.
What that costs is five complete product forms and five review-widget mounts in the server HTML for a set of five, on a page that already carries the main form. The card's tap target is a stacked overlay, which is the kind of CSS that has to be revisited whenever the card's layout changes. And because curation is per product, a newly launched product appears in no set until someone edits every product that should carry it — the flexibility of authored sets is also their maintenance bill.
There's no discount logic here either. The saving a card shows is the product's own compare-at price. If a bundle needs a price that only exists when the items are bought together, this block doesn't make one.
Why Not an App
Bundle and upsell apps render their rail into the product page after paint, from the app's own script, with the app's own add-to-cart. This block is server-rendered inside the product section, so the set is in the first byte of HTML, it needs no script to exist, and it adds through the theme's own form. There's no third-party script behind the rail and no second cart pipeline running beside Dawn's.
What an app would bring, and this doesn't, is the layer above rendering: bundle-only pricing, analytics on which sets convert, rules that assemble sets automatically. If those are the need, the honest answer is an app with a theme that renders around it. Here the need was a curated set with correct prices and a one-tap add, and that's a rendering problem.
Implementation Notes
- The
bundle_and_saveblock is present and enabled in all four product templates —product,product.new-design,product.kids-new-designandproduct.waiting-list— and is limited to one instance per section. - Slider mode is a count check on the metafield: more than one product and the container takes
swiperclasses and the block-scoped DOM id; exactly one and the card renders static, with no pagination element and no script. - Form ids are composed as
bundle-and-save-form-<block.id>-<index>, so several cards on one page — and the same block on two sections — never collide with each other or with the main product form. {% render 'price' %}is called with the explicit variant andshow_badges: false, which keeps the savings badge and the sale styling but drops the "Sale" and "Sold out" tags a full product form would show.- The supporting line reads
custom.bundle_sub_texton the recommended product, not the host, so it's written once per product and appears wherever that product is curated. - The hidden variant input carries the
product-variant-idclass Dawn's form script looks for, anddata-hide-errors="false"gives each card its ownrole="alert"error wrapper. - The image is an
image_tagwith widths of 150, 250, 350 and 500 pixels and asizeshint of roughly 180 pixels on desktop and 100 on mobile, loaded lazily — a card, not a hero.
Edge Cases
- An empty or missing
custom.bundle_and_saverenders nothing at all: the block is in the template, but the page carries no heading and no empty rail. - A product whose
selected_or_first_available_variantis blank or unavailable gets both its hidden variant input and its submit buttondisabledin the markup, so the card still shows the product and its price but can't post an add that would fail. - In slider mode the full-card link overlay is pushed to
z-index: -1, so a horizontal swipe is read by Swiper rather than caught by the link; the title and image links still work. - Add failures surface inline in the card's own error wrapper, because
data-hide-errorsisfalse, rather than disappearing into the drawer. - A product with no featured image still renders its link, its title, its price and its form; the image slot is simply empty and the link keeps its
aria-label.
Platform Primitives Used
- Metafields —
custom.bundle_and_save, a product-list field, is the whole data source;custom.bundle_sub_texton each recommended product supplies its supporting line. - Cart AJAX API — every card's form posts to the cart add route through Dawn's
<product-form>, with the drawer's sections requested in the same call. - Liquid section schema and settings — a block type with three text settings, one instance per section, dispatched by the product section's block switch.
Integrations in Play
- Swiper — the carousel is the theme's globally enqueued Swiper; the block constructs an instance against its own DOM id with clickable pagination, and only when there's more than one card.
- Yotpo — each card places the vendor's review-widget mount for that product. The stars are Yotpo's to fill; the block doesn't render a rating of its own.
Where It Runs
The product detail page, on all four product templates, as a block of the rebuilt 2026 product section — which dispatches it from its {% case block.type %} alongside the section's other blocks.
The same section offers a second block type, pairs_well_with, driven by product.metafields.custom.pairs_well_with.
What This Demonstrates
- Product recommendations and cross-sell — the primary capability: a curated cross-sell rail that sells from the card, as the authored counterpart to a recommendations-API rail.
- Metafield-driven PDP content blocks — the set is product data, and the block is one more thing a merchandiser composes in the theme editor.
- AJAX cart drawer and line-item rules without an app — several product forms on one page, all adding through the theme's own drawer path.
How We Know
One snippet of roughly 230 lines, read from the theme, plus two metafield exports and three client working-session records. The exports list custom.bundle_and_save and custom.bundle_sub_text as new fields — the data model was created for this block. The session records track the set's content as a merchandising task and show the product-to-product mapping arriving from the client as a document to be entered, which is the strongest evidence we have that curation, not computation, was the requirement.
Related Builds
- Rebuilt 2026 PDP section with merchandiser-composable blocks — LUS Brands. The section this block lives in, and the block-switch that renders it.
- Cart 'Add Ons' cross-sell driven by the Product Recommendations API — Nudestix. The computed counterpart: Shopify's endpoint instead of a curated list, in the cart instead of on the product page.
- "Save With Sets": shade-selectable kit contents on the component product's page — Nudestix. Set merchandising where the set is a real kit product with its own price.
- Inline size picker on cart and PDP upsell cards — Three Ships. A cross-sell card that has to choose a variant before it can add; this block sidesteps that by adding the first available one.
The Buy-vs-Build Question
A recommendations app buys you a rail that decides what to show. This block decides nothing — it renders what a merchandiser chose, with the theme's own prices and the theme's own add. Whether that's enough depends on whether your cross-sell is a merchandising opinion or a data problem, and that's the question we work through in Product recommendations: buy or build?
Provenance & Evidence
- Client: LUS Brands — curly-hair care, loveurcurls.com
- Surface: Product detail page
- Templates served: Four —
product,product.new-design,product.kids-new-design,product.waiting-list - Complexity: Medium — one snippet, roughly 230 lines
- Attribution: Deploi-authored. The snippet, the block schema and the metafield convention are ours. It runs inside a product section of our own on a storefront whose theme base is Dawn 15.4.1, and the
<product-form>element andpricesnippet it calls are Dawn's. - Status: Live, verified 2026-09-07
- Evidence: One theme snippet, two metafield exports, three client working-session records
- Confidence: Strong — code plus client records
- Primary capability: Product recommendations and cross-sell
Ready to Sell the Routine, Not Just the Product?
If your best cross-sell is an opinion your merchandisers already hold, it belongs in product data, not in an app's dashboard. Contact us today and we'll put the set on the product page with real prices and a real add button.