Offering Tiered Discounts with a custom bundles widget
Tiered discounts let you incentivize larger bundle purchases by offering higher discounts as the total quantity increases.
This guide shows you how to:
- Fetch tiered discounts from the Recharge CDN
- Filter discounts based on selling plan and charge type
- Determine the active tier based on bundle quantity
- Calculate estimated storefront pricing
- Render a progress bar UI
Where the data lives
Recharge stores tiered discount incentives in the CDN and lets you fetch them using the storefront client’s getCdnBundleSettings() method. See the Recharge JavaScript SDK documentation for more details.
Each tiered_discount object contains:
idnamelookup_keystatus—"published"or"unpublished"eligible_charge_types—["recurring", "checkout"]eligible_line_item_types—["subscription"]or["onetime"]tiers— Array of tier objects, each containing:condition_quantity_gte— Minimum quantity required to qualify for the tierdiscount_type—"percentage"or"fixed_amount"discount_value— The discount amount
Storefront pricing calculation flow
Shopify Functions apply the final discount after the Add-to-Cart step. However, if you want to display an estimated discounted price in your storefront widget before the item is added to the cart, you must replicate the pricing logic on the client side.
To calculate the estimated price:
- Filter eligible tiered discounts: Filter by charge type (checkout vs recurring) and line item type (subscription vs one-time).
- Determine the active tier: Identify the highest qualifying tier based on the customer’s current total item count.
- Calculate the discounted price: Apply the subscription discount first (if applicable), then apply the tiered discount.
Building a Tiered Discounts Progress Bar
You can build your own tiered discounts progress bar for a custom Recharge bundles widget. You can build your own tiered discounts progress bar for a custom Recharge bundles widget. See the data source, type definitions, and example CDN payload below for reference.
Data source
Fetch bundle data from the Recharge CDN using the Storefront Client:
import { getCdnBundleSettings } from '@rechargeapps/storefront-client';
const bundleData = await getCdnBundleSettings({
externalProductId: 'YOUR_PRODUCT_ID'
});
const tieredDiscounts =
bundleData.bundle_product.incentives?.tiered_discounts || [];Type defintions
type TieredDiscount = {
id: number;
name: string; // e.g., "Subscription only"
lookup_key: string; // e.g., "MKTME"
status: 'published' | 'unpublished';
eligible_charge_types: ('checkout' | 'recurring')[];
eligible_line_item_types: ('subscription' | 'onetime')[];
external_bundle_product_id: { ecommerce: string };
tiers: Array<{
tier: {
condition_quantity_gte: number; // Min items for this tier (e.g., 2, 4, 6, 8)
discount_type: 'percentage' | 'fixed_amount';
discount_value: string; // e.g., "10.00", "15.00"
lookup_key: string;
};
}>;
};
type SelectedProduct = {
quantity: number;
collectionId: string;
externalProductId: string;
externalVariantId: string;
};
// The "onetime" selling plan has id = 0
const ONETIME_SELLING_PLAN_ID = 0;Example CDN Payload
Here's an example from Recharge's Test Bundles Store:
{
"incentives": {
"tiered_discounts": [
{
"eligible_charge_types": ["recurring", "checkout"],
"eligible_line_item_types": ["subscription"],
"id": 2688,
"lookup_key": "MKTME",
"name": "Subscription only",
"status": "published",
"tiers": [
{"tier": {"condition_quantity_gte": 2, "discount_type": "percentage", "discount_value": "10.00", "lookup_key": "MKTME-0"}},
{"tier": {"condition_quantity_gte": 4, "discount_type": "percentage", "discount_value": "15.00", "lookup_key": "MKTME-1"}},
{"tier": {"condition_quantity_gte": 6, "discount_type": "percentage", "discount_value": "20.00", "lookup_key": "MKTME-2"}},
{"tier": {"condition_quantity_gte": 8, "discount_type": "percentage", "discount_value": "25.00", "lookup_key": "MKTME-3"}}
]
},
{
"eligible_charge_types": ["recurring", "checkout"],
"eligible_line_item_types": ["onetime"],
"id": 2687,
"lookup_key": "7L1AD",
"name": "One time only",
"status": "published",
"tiers": [
{"tier": {"condition_quantity_gte": 4, "discount_type": "percentage", "discount_value": "10.00", "lookup_key": "7L1AD-0"}},
{"tier": {"condition_quantity_gte": 6, "discount_type": "percentage", "discount_value": "15.00", "lookup_key": "7L1AD-1"}},
{"tier": {"condition_quantity_gte": 8, "discount_type": "percentage", "discount_value": "20.00", "lookup_key": "7L1AD-2"}}
]
}
]
}
}Implementation guide
Step 1: Filter tiered discounts during initialization
Only work with published tiered discounts from the CDN:
const activeTieredDiscounts =
(bundleData.bundle_product.incentives?.tiered_discounts || [])
.filter(({ status }) => status === 'published');Step 2: Get eligible tiered discounts
Filter tiered discounts by charge type (checkout vs recurring) and line item type (subscription vs onetime):
/**
* Get tiered discounts that are eligible for the given charge type and selling plan.
*
* @param tieredDiscounts - All tiered discounts from CDN
* @param chargeType - 'checkout' for first purchase, 'recurring' for subsequent charges
* @param sellingPlanId - The selected selling plan ID (0 or null = onetime)
* @returns Array of eligible tiered discounts
*/
function getEligibleTieredDiscounts(
tieredDiscounts: TieredDiscount[],
chargeType: 'checkout' | 'recurring',
sellingPlanId: number | string | null
): TieredDiscount[] {
// Determine if this is a subscription or onetime based on selling plan ID
// Onetime has id = 0 or null, any other ID is a subscription
const isOnetime = !sellingPlanId || Number(sellingPlanId) === ONETIME_SELLING_PLAN_ID;
const lineItemType = isOnetime ? 'onetime' : 'subscription';
return tieredDiscounts.filter(
({ eligible_charge_types, eligible_line_item_types, status }) =>
status === 'published' &&
eligible_charge_types.includes(chargeType) &&
eligible_line_item_types.includes(lineItemType)
);
}Step 3: Get the active tiered discount and enrich tiers
Select the first eligible tiered discount and enrich its tiers with helper methods (such as getting pending quantity):
/**
* Get the active tiered discount with enriched tier data.
* Returns the first eligible tiered discount (merchants typically configure one per line item type).
*
* @param tieredDiscounts - All tiered discounts from CDN
* @param chargeType - 'checkout' for first purchase, 'recurring' for subsequent charges
* @param sellingPlanId - The selected selling plan ID
* @param maxBundleQuantity - Maximum items allowed in bundle (to filter unreachable tiers)
* @returns Tiered discount with enriched tiers, or null if none eligible
*/
function getActiveTieredDiscount(
tieredDiscounts: TieredDiscount[],
chargeType: 'checkout' | 'recurring',
sellingPlanId: number | string | null,
maxBundleQuantity: number = Infinity
) {
const eligible = getEligibleTieredDiscounts(tieredDiscounts, chargeType, sellingPlanId);
if (eligible.length === 0) return null;
const discount = eligible[0];
return {
...discount,
tiers: discount.tiers
// Only include tiers that are achievable within the bundle's max quantity
.filter(({ tier }) => Number(tier.condition_quantity_gte) <= maxBundleQuantity)
// Sort by quantity threshold ascending
.sort((a, b) =>
Number(a.tier.condition_quantity_gte) - Number(b.tier.condition_quantity_gte)
)
// Enrich each tier with a helper method
.map(({ tier }) => ({
...tier,
/**
* Calculate how many more items needed to reach this tier
* @param count - Current number of items selected
* @returns Number of items still needed (0 if tier is already reached)
*/
getPending(count: number) {
const threshold = Number(tier.condition_quantity_gte);
return Math.max(0, threshold - count);
},
})),
};
}Step 4: Get the current tier based on the selection
Determine which tier the customer currently qualifies for based on the total selected quantity:
// Optional: Cache to avoid recalculating tiers for the same quantity
const tierCache: Record<string, Record<number, any>> = {};
/**
* Get the current tier based on total items selected.
*
* @param activeTieredDiscount - The active tiered discount (from getActiveTieredDiscount)
* @param totalSelected - Total number of items in the bundle selection
* @returns The tier object the customer qualifies for, or null
*/
function getCurrentTier(
activeTieredDiscount: ReturnType<typeof getActiveTieredDiscount>,
totalSelected: number
) {
if (!activeTieredDiscount) return null;
// Check cache first (optional optimization)
const cacheKey = activeTieredDiscount.lookup_key;
if (tierCache[cacheKey]?.[totalSelected]) {
return tierCache[cacheKey][totalSelected];
}
// Find all tiers the customer qualifies for (where their count >= threshold)
// Tiers are already sorted ascending, so we want the LAST qualifying tier
const qualifyingTiers = activeTieredDiscount.tiers.filter(
tier => Number(tier.condition_quantity_gte) <= totalSelected
);
// Get the highest tier (last in sorted array)
const currentTier = qualifyingTiers[qualifyingTiers.length - 1] || null;
// Cache result (optional)
if (!tierCache[cacheKey]) tierCache[cacheKey] = {};
tierCache[cacheKey][totalSelected] = currentTier;
return currentTier;
}Step 5: Calculate discounted prices
Apply subscription discounts and then tiered discounts to compute estimated prices for variants and the full bundle:
/**
* Helper function to calculate discount
*/
function calculateDiscount(
price: number,
discountValue: number,
discountType: 'percentage' | 'fixed_amount' | string
): number {
if (discountType === 'percentage') {
return price * (1 - discountValue / 100);
}
return Math.max(0, price - discountValue);
}
/**
* Calculate the price of a product variant with all applicable discounts.
*
* @param variant - The product variant with price info
* @param subscriptionDiscount - Optional subscription discount from selling plan
* @param tieredDiscount - Optional tiered discount tier
* @returns Object with discounted price and compareAtPrice
*/
function calculateVariantPrice(
variant: { price: number; compare_at_price: number | null },
subscriptionDiscount?: { value: number; type: 'percentage' | 'fixed_amount' },
tieredDiscount?: { discount_value: string; discount_type: 'percentage' | 'fixed_amount' } | null
) {
// Compare at price is the higher of the two (for showing "was $X, now $Y")
const compareAtPrice = Math.max(variant.compare_at_price || 0, variant.price);
// Start with base price
let price = variant.price;
// Apply subscription discount first (if any)
if (subscriptionDiscount) {
price = calculateDiscount(price, subscriptionDiscount.value, subscriptionDiscount.type);
}
// Then apply tiered discount on top (if any)
if (tieredDiscount) {
price = calculateDiscount(
price,
Number(tieredDiscount.discount_value),
tieredDiscount.discount_type
);
}
return { price, compareAtPrice };
}
/**
* Calculate total bundle price for all selected products.
*
* @param selectedProducts - Array of selected products with quantities and variant info
* @param getVariantPrice - Function to get variant price/compare_at_price by ID
* @param subscriptionDiscount - Optional subscription discount
* @param currentTier - Current tiered discount tier (or null)
* @returns Object with total price and compareAtPrice
*/
function calculateBundlePrice(
selectedProducts: Array<{ quantity: number; variant: { price: number; compare_at_price: number | null } }>,
subscriptionDiscount?: { value: number; type: 'percentage' | 'fixed_amount' },
currentTier?: { discount_value: string; discount_type: 'percentage' | 'fixed_amount' } | null
) {
let totalPrice = 0;
let totalCompareAtPrice = 0;
selectedProducts.forEach(({ quantity, variant }) => {
const { price, compareAtPrice } = calculateVariantPrice(
variant,
subscriptionDiscount,
currentTier
);
totalPrice += price * quantity;
totalCompareAtPrice += compareAtPrice * quantity;
});
return { price: totalPrice, compareAtPrice: totalCompareAtPrice };
}Progress bar UI implementation
Calculate progress segments
Convert tiers into UI-friendly “segments” that include:
- Threshold (items required)
- Label (discount text)
- Description (helper text)
- Fill percent (segment progress)
- Active state (reached vs not reached)
type ProgressSegment = {
threshold: number;
label: string;
description: string;
fillPercent: number;
isActive: boolean;
};
/**
* Calculate progress segments for the UI
*/
function getProgressSegments(
activeTieredDiscount: ReturnType<typeof getActiveTieredDiscount>,
totalSelected: number
): ProgressSegment[] {
if (!activeTieredDiscount) return [];
return activeTieredDiscount.tiers.map((tier, index, list) => {
const threshold = Number(tier.condition_quantity_gte);
const prevThreshold = index > 0 ? Number(list[index - 1].condition_quantity_gte) : 0;
const remaining = tier.getPending(totalSelected);
const isActive = totalSelected >= threshold;
// Calculate fill percentage for this segment
let fillPercent = 0;
if (totalSelected >= threshold) {
fillPercent = 100;
} else if (totalSelected > prevThreshold) {
fillPercent = ((totalSelected - prevThreshold) / (threshold - prevThreshold)) * 100;
}
// Format the discount label
const discountLabel = tier.discount_type === 'percentage'
? `${tier.discount_value}% off`
: `$${tier.discount_value} off`;
// Generate description message
let description: string;
if (remaining > 0) {
description = `Add ${remaining} more for ${discountLabel}`;
} else if (index === list.length - 1) {
description = `You're getting ${discountLabel}!`;
} else {
description = `${discountLabel} unlocked!`;
}
return {
threshold,
label: discountLabel,
description,
fillPercent,
isActive,
};
});
}
React component example
interface TieredDiscountProgressBarProps {
tieredDiscounts: TieredDiscount[];
sellingPlanId: number | string | null;
totalSelected: number;
maxBundleQuantity?: number;
}
function TieredDiscountProgressBar({
tieredDiscounts,
sellingPlanId,
totalSelected,
maxBundleQuantity = Infinity
}: TieredDiscountProgressBarProps) {
// Get the active tiered discount
const activeTieredDiscount = getActiveTieredDiscount(
tieredDiscounts,
'checkout', // Use 'checkout' for storefront display
sellingPlanId,
maxBundleQuantity
);
if (!activeTieredDiscount) return null;
const segments = getProgressSegments(activeTieredDiscount, totalSelected);
const activeSegment = segments.find(s => !s.isActive) || segments[segments.length - 1];
return (
<div className="tiered-discount-progress">
<div className="segments-container">
{segments.map((segment, index) => (
<div
key={index}
className={`segment ${segment.isActive ? 'active' : ''}`}
>
<div
className="fill"
style={{ width: `${segment.fillPercent}%` }}
/>
<span className="label">{segment.label}</span>
</div>
))}
</div>
<p className="description">{activeSegment?.description}</p>
</div>
);
}
Complete usage example
The following example shows how to integrate tiered discount logic and the progress bar into a custom bundle widget.
function BundleSummary() {
// Get bundle data from context/state
const {
bundleData,
selectedProducts,
selectedSellingPlanId,
selectedVariantId
} = useBundleContext();
// Get tiered discounts from CDN data
const tieredDiscounts = bundleData.bundle_product.incentives?.tiered_discounts || [];
// Get max quantity for the selected bundle variant
const maxQuantity = bundleData.bundle_product.variants
.find(v => v.external_variant_id === selectedVariantId)
?.ranges[0]?.quantity_max || Infinity;
// Calculate total items selected
const totalSelected = selectedProducts.reduce(
(sum, product) => sum + product.quantity,
0
);
// Get the active tiered discount
const activeTieredDiscount = getActiveTieredDiscount(
tieredDiscounts,
'checkout',
selectedSellingPlanId,
maxQuantity
);
// Get current tier for price calculation
const currentTier = getCurrentTier(activeTieredDiscount, totalSelected);
// Calculate the bundle price with tiered discount applied
const bundlePrice = calculateBundlePrice(
selectedProducts.map(p => ({
quantity: p.quantity,
variant: getVariantById(p.externalVariantId) // Your variant lookup function
})),
getSubscriptionDiscount(selectedSellingPlanId), // Your selling plan discount lookup
currentTier
);
return (
<div className="bundle-summary">
{/* Progress bar */}
<TieredDiscountProgressBar
tieredDiscounts={tieredDiscounts}
sellingPlanId={selectedSellingPlanId}
totalSelected={totalSelected}
maxBundleQuantity={maxQuantity}
/>
{/* Price display */}
<div className="price-display">
{bundlePrice.compareAtPrice > bundlePrice.price && (
<span className="compare-at-price">
${bundlePrice.compareAtPrice.toFixed(2)}
</span>
)}
<span className="current-price">
${bundlePrice.price.toFixed(2)}
</span>
</div>
</div>
);
}
CSS example
.tiered-discount-progress {
padding: 16px;
background: #f0f7ff;
border-radius: 8px;
}
.segments-container {
display: flex;
gap: 4px;
}
.segment {
flex: 1;
position: relative;
height: 32px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.segment:first-child {
border-radius: 4px 0 0 4px;
}
.segment:last-child {
border-radius: 0 4px 4px 0;
}
.segment .fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #4caf50;
transition: width 0.3s ease;
}
.segment.active .fill {
background: #2e7d32;
}
.segment .label {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 12px;
font-weight: 600;
color: #333;
}
.segment.active .label {
color: #fff;
}
.description {
margin-top: 8px;
font-size: 14px;
color: #666;
}
.price-display {
margin-top: 16px;
display: flex;
gap: 8px;
align-items: baseline;
}
.compare-at-price {
text-decoration: line-through;
color: #999;
font-size: 14px;
}
.current-price {
font-size: 24px;
font-weight: 700;
color: #2e7d32;
}
Important notes
-
Storefront pricing is an estimate
The final discount is applied by a Shopify Function after the Add-to-Cart step. Any pricing shown in your storefront widget is an estimate. Clearly communicate this to customers (for example, “Discount applied at checkout”). -
Dynamic bundles only
Tiered discounts are supported only for dynamic-priced bundles wherebundle_product.price_rule === "dynamic". -
Subscription vs. one-time purchases
Always filter tiered discounts based on the selected purchase type (subscriptionvsonetime). Each purchase type may have a different discount structure. -
Charge types
checkoutapplies to the first purchase.recurringapplies to subsequent subscription renewals.
For storefront price display, you will typically usecheckout.
-
Maximum quantity filtering
Filter out tiers that exceed the bundle’s maximum allowed quantity to avoid displaying discounts that customers cannot reach.
Updated about 1 month ago
