Development of Cross-Sell and Up-Sell Blocks for E-Commerce
Cross-sell and up-sell are two distinct mechanisms for increasing average order value that are often confused. Cross-sell is the offer of complementary products ("this mouse works with this laptop"). Up-sell is the offer of a more expensive version of the same product ("for 2000 ₽ more — the Pro version"). Development of both blocks takes 4–6 business days.
Distinguishing Cross-Sell from Up-Sell
| Parameter | Cross-Sell | Up-Sell |
|---|---|---|
| What is offered | Complementary products | Premium version of current |
| Where shown | Product page, cart | Product page, before adding to cart |
| Metric | Increase number of items | Increase amount per item |
| Example | Phone case | 128 GB instead of 64 GB |
Data Model: Manual Relationships
For small catalogs — manual relationship assignment in the admin panel:
CREATE TABLE product_relations (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
related_product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL, -- 'cross_sell', 'up_sell', 'accessory', 'spare_part'
sort_order SMALLINT DEFAULT 0,
UNIQUE(product_id, related_product_id, type)
);
CREATE INDEX idx_product_relations_pid_type ON product_relations(product_id, type);
In the admin panel — product search and relationship addition via drag-and-drop with type selection.
Automatic Cross-Sell via Categories
If relationships are not manually set — automatic fallback based on co-purchases or accessory categories:
class CrossSellResolver
{
public function resolve(Product $product, int $limit = 4): Collection
{
// 1. Manual relationships
$manual = $product->relations()
->where('type', 'cross_sell')
->with('relatedProduct')
->orderBy('sort_order')
->limit($limit)
->get()
->map(fn($r) => $r->relatedProduct);
if ($manual->count() >= $limit) return $manual;
// 2. Automatic from cooccurrences (if sufficient data)
$needed = $limit - $manual->count();
$auto = DB::table('product_cooccurrences')
->where('product_a', $product->id)
->whereNotIn('product_b', $manual->pluck('id'))
->orderByDesc('cooccurrence_count')
->limit($needed)
->pluck('product_b');
$autoProducts = Product::whereIn('id', $auto)->where('is_active', true)->get();
return $manual->merge($autoProducts);
}
}
Up-Sell: Variants of One Product
For variant products (phones with different storage) up-sell is navigation between variants with emphasis on premium:
const UpSellVariants = ({ currentVariant, variants }: UpSellProps) => {
const betterVariants = variants.filter(v => v.price > currentVariant.price);
if (!betterVariants.length) return null;
return (
<div className="border rounded-lg p-4 bg-amber-50">
<p className="text-sm font-medium mb-2">Consider the upgraded version:</p>
{betterVariants.slice(0, 2).map(variant => (
<div key={variant.id} className="flex items-center justify-between py-2">
<span className="text-sm">{variant.label}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">
+{formatPrice(variant.price - currentVariant.price)}
</span>
<Button size="sm" variant="outline" onClick={() => selectVariant(variant)}>
Select
</Button>
</div>
</div>
))}
</div>
);
};
Cross-Sell in Cart: "Complete Your Order"
The most conversion-friendly moment for cross-sell is the cart page. The "Frequently Bought Together" block aggregates recommendations from all products in the cart:
public function getCartCrossSells(Cart $cart): Collection
{
$cartProductIds = $cart->items->pluck('product_id');
// Combine cross-sell recommendations from all cart items
$recommendations = DB::table('product_relations')
->join('products', 'products.id', '=', 'product_relations.related_product_id')
->whereIn('product_relations.product_id', $cartProductIds)
->whereNotIn('product_relations.related_product_id', $cartProductIds)
->where('product_relations.type', 'cross_sell')
->where('products.is_active', true)
->where('products.stock', '>', 0)
->select('products.*', DB::raw('COUNT(*) as relevance_score'))
->groupBy('products.id')
->orderByDesc('relevance_score')
->limit(4)
->get();
return $recommendations;
}
A product recommended by multiple cart items receives a higher relevance_score and is displayed first.
Quick-Add in Recommendation Block
"Add to Cart" button directly in cross-sell card — without going to product page:
const CrossSellCard = ({ product }: { product: Product }) => {
const { addToCart, isLoading } = useCart();
return (
<div className="border rounded-lg p-3 flex gap-3">
<img src={product.thumb} alt={product.name} className="w-16 h-16 object-cover rounded" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{product.name}</p>
<p className="text-sm text-gray-700">{formatPrice(product.price)}</p>
</div>
<Button
size="sm"
loading={isLoading(product.id)}
onClick={() => addToCart(product.id, 1)}
>
+ Add to Cart
</Button>
</div>
);
};
Bundle: Fixed Sets
A separate type of cross-sell — a bundle with set discount:
CREATE TABLE product_bundles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
discount_percent NUMERIC(5,2) DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE
);
CREATE TABLE product_bundle_items (
bundle_id BIGINT REFERENCES product_bundles(id) ON DELETE CASCADE,
product_id BIGINT REFERENCES products(id),
is_primary BOOLEAN DEFAULT FALSE,
PRIMARY KEY(bundle_id, product_id)
);
On the main product page, a "Buy as Bundle" block is shown: all products in the bundle + total price with discount. Adding to cart — with one button.
Analytics of Block Effectiveness
Each cross-sell/up-sell impression is logged. Metrics:
- Impressions — how many times the block was shown
- CTR — clicks on recommendations / impressions
- Add-to-cart rate — additions / clicks
- Uplift — average order value with cross-sell vs without
Data allows optimization of placement, number of recommendations, and algorithm selection. Usually 4 recommendations show better CTR than 2 or 8.







