Why 'everything has a SALE badge' is bad UX, and how context-aware signals solve the badge explosion problem.
•7 min readYou've seen this anti-pattern on every e-commerce site:
A product grid where every single item has a "SALE" badge. Or "NEW." Or "BESTSELLER." When everything is special, nothing is.
The problem isn't badges. It's that badges are stored content, not derived state. Someone manually marked 500 products as "sale" and forgot to unmark them. Or the system shows every applicable badge regardless of context.
We built Product Signals to fix this.
A signal isn't a field on a product. It's computed from product state at read time.
// config/product-signals.ts
const PRODUCT_SIGNAL_TYPES = {
low_stock: {
eligibility: (product) => {
const trackingVariants = product.variants.filter(
(v) => v.isActive && v.trackInventory,
);
if (trackingVariants.length === 0) return false;
const totalStock = trackingVariants.reduce((sum, v) => sum + v.stock, 0);
return totalStock > 0 && totalStock <= 5;
},
extract: (product) => {
const totalStock = product.variants
.filter((v) => v.isActive && v.trackInventory)
.reduce((sum, v) => sum + v.stock, 0);
return {
label: "Low Stock",
value: totalStock === 1 ? "Only 1 left" : `Only ${totalStock} left`,
variant: "urgent",
icon: "alert-triangle",
};
},
priority: 9,
ttl: "realtime",
},
};
Key insight: eligibility determines IF a signal shows. extract determines WHAT it shows. Both run against current product state.
No one marks a product as "low stock." The system sees 3 units in inventory and derives the signal.
Eligibility: Product is on sale with at least one variant having salePrice < listPrice Display: "Sale — 25% off" (computed from actual price difference) Priority: 10 (highest)
discount: {
eligibility: (p) => {
if (!p.isOnSale) return false
return p.variants.some(v =>
v.isActive && v.salePrice !== null && v.listPrice > v.salePrice
)
},
extract: (p) => {
let maxDiscount = 0
for (const v of p.variants) {
if (v.isActive && v.salePrice && v.listPrice > v.salePrice) {
const discount = Math.round(((v.listPrice - v.salePrice) / v.listPrice) * 100)
if (discount > maxDiscount) maxDiscount = discount
}
}
return {
label: "Sale",
value: maxDiscount > 0 ? `${maxDiscount}% off` : "On Sale",
variant: "positive",
icon: "tag"
}
}
}
The discount percentage is computed, not stored. When prices change, signals update automatically.
Eligibility: Total inventory across active, tracking variants ≤ 5 Display: "Only 3 left" Priority: 9
Creates urgency from real inventory state. Sells out → signal disappears. Restocked → signal disappears. No manual intervention.
Eligibility: Product is featured AND stock ≤ 20 Display: "Limited Edition — 12 remaining" Priority: 8
Combines manual curation (isFeatured) with inventory reality. The "limited" claim is always true.
Eligibility: Last sale within 3 days AND ≥ 3 conversions Display: "Trending — 8 sold recently" Priority: 7
Derived from sales velocity. A product can't be "trending" if no one's buying it.
Eligibility: ≥ 5 reviews AND average rating ≥ 4.0 Display: "Top Rated — 4.7 stars (23 reviews)" Priority: 6
Social proof that requires actual reviews. Can't game it by marking products "top rated."
Eligibility: Published within last 14 days Display: "New" Priority: 5
Time-based, automatic. After 14 days, signal disappears. No cleanup needed.
Eligibility: Combined likes + wishlists ≥ 50 Display: "Popular — 200+ saves" Priority: 4
Engagement-driven. Real user interest, not editorial picks.
Here's where it gets interesting. Signals know where they're being displayed.
type SignalContextKey =
| "product_page" // Single product view
| "feed" // Feed/homepage
| "curation" // Collection page
| "sale_curation" // Discount curation
| "new_curation" // New arrivals
| "search_results" // Search
| "cart" // Cart page
| "related_products"; // Related section
Each context defines:
product_page: {
allowedTypes: null, // All types allowed
suppressedTypes: [],
maxSignals: 4,
coherenceMode: "none" // Single product, coherence N/A
}
User is focused on one product. Show all applicable signals: "Sale 30% off" + "Only 2 left" + "Top Rated 4.8 stars" + "Trending."
feed: {
allowedTypes: null,
suppressedTypes: [],
maxSignals: 1, // Only one signal per product
coherenceMode: "diverse" // Orchestrator ensures variety
}
In a product grid, you don't want 20 products all showing "SALE." Coherence mode "diverse" ensures the system picks different signal types across products:
Product 1: "Sale 25% off"
Product 2: "Low Stock - 2 left"
Product 3: "New"
Product 4: "Trending"
Product 5: "Top Rated"
Product 6: "Sale 40% off" // Sale allowed again, enough variety
sale_curation: {
allowedTypes: ["discount"], // ONLY discount
suppressedTypes: [],
maxSignals: 1,
coherenceMode: "consistent", // All products show same type
enforceType: "discount"
}
When viewing the "Sale" curation, every product should show its discount. That's the context. Showing "New" or "Trending" would be confusing.
cart: {
allowedTypes: ["low_stock", "limited_edition", "discount"],
suppressedTypes: ["new_arrival", "popular", "trending"],
maxSignals: 1,
coherenceMode: "none"
}
In the cart, we want to drive conversion. "Only 2 left" creates urgency. "New" is irrelevant — they've already decided to buy.
related_products: {
allowedTypes: ["discount", "low_stock"],
suppressedTypes: ["new_arrival", "popular", "trending", "top_rated"],
maxSignals: 1,
coherenceMode: "none"
}
Related products shouldn't distract from the main product. Only show signals that might tip the "add to cart" decision.
// lib/product-signals.ts
export function resolveProductSignals(
product: SignalableProduct,
context: SignalContextKey,
): ResolvedSignal[] {
const contextDef = SIGNAL_CONTEXTS[context];
const signals: ResolvedSignal[] = [];
for (const [key, definition] of Object.entries(PRODUCT_SIGNAL_TYPES)) {
// Skip if type not allowed in this context
if (contextDef.allowedTypes && !contextDef.allowedTypes.includes(key))
continue;
// Skip if type is suppressed in this context
if (contextDef.suppressedTypes.includes(key)) continue;
// Check eligibility
if (!definition.eligibility(product)) continue;
// Extract display data
const display = definition.extract(product);
signals.push({
type: key,
priority: definition.priority,
...display,
});
}
// Sort by priority (highest first)
signals.sort((a, b) => b.priority - a.priority);
// Limit to context's maxSignals
return signals.slice(0, contextDef.maxSignals);
}
The function takes a product and a context. It returns only the signals that are:
For multi-product views with coherenceMode: "diverse", we need global coordination:
// lib/product-signals.ts
export function resolveSignalsForProductSet(
products: SignalableProduct[],
context: SignalContextKey,
): Map<string, ResolvedSignal[]> {
const contextDef = SIGNAL_CONTEXTS[context];
const results = new Map<string, ResolvedSignal[]>();
if (contextDef.coherenceMode === "diverse") {
// Track which signal types have been used recently
const recentTypes: SignalTypeKey[] = [];
const DIVERSITY_WINDOW = 3; // Don't repeat within 3 products
for (const product of products) {
const candidates = resolveProductSignals(product, context);
// Prefer signals not used in the last 3 products
const preferred = candidates.find((c) => !recentTypes.includes(c.type));
const selected = preferred ?? candidates[0];
if (selected) {
results.set(product.id, [selected]);
recentTypes.push(selected.type);
if (recentTypes.length > DIVERSITY_WINDOW) recentTypes.shift();
} else {
results.set(product.id, []);
}
}
} else {
// Non-diverse: resolve each product independently
for (const product of products) {
results.set(product.id, resolveProductSignals(product, context));
}
}
return results;
}
The diversity algorithm ensures that even if 10 products all have "Sale" as their highest-priority eligible signal, the grid shows a mix.
Single file change. No migration. No component updates.
// In config/product-signals.ts, add to PRODUCT_SIGNAL_TYPES:
back_in_stock: {
label: "Back in Stock",
eligibility: (p) => {
// Was out of stock, now has stock, restocked within 3 days
const trackingVariants = p.variants.filter(v => v.isActive && v.trackInventory)
if (trackingVariants.length === 0) return false
const totalStock = trackingVariants.reduce((sum, v) => sum + v.stock, 0)
if (totalStock === 0) return false
// Check if product was recently restocked
if (!p.lastRestockedAt) return false
const daysSinceRestock = (Date.now() - p.lastRestockedAt.getTime()) / (1000 * 60 * 60 * 24)
return daysSinceRestock <= 3
},
extract: () => ({
label: "Back in Stock",
value: null,
sublabel: null,
variant: "positive",
icon: "refresh"
}),
priority: 7.5, // Between trending and limited_edition
ttl: "hours",
defaultVariant: "positive"
}
The signal immediately works everywhere. Contexts that allow all types will show it. The feed feed will include it in the diversity rotation.
Before:
Product 1: SALE
Product 2: SALE
Product 3: SALE
Product 4: NEW
Product 5: SALE
Product 6: SALE
After (diverse coherence):
Product 1: Sale 30% off
Product 2: Only 2 left
Product 3: Trending
Product 4: New
Product 5: Top Rated
Product 6: Sale 25% off
Visual variety. Information density. Each signal provides unique value.
Signals declare how fresh they need to be:
ttl: "realtime"; // low_stock — inventory changes constantly
ttl: "minutes"; // discount — prices might change during flash sales
ttl: "hours"; // trending, top_rated — aggregates are stable
This informs caching strategy. Low stock signals bypass cache. Top rated signals can be cached for hours.
SignalTypeKey is derived from the registry:
const _PRODUCT_SIGNAL_TYPES = { ... } satisfies Record<string, SignalTypeDefinition>
export type SignalTypeKey = keyof typeof _PRODUCT_SIGNAL_TYPES
Add a signal to the registry → type automatically includes it. Remove a signal → type errors everywhere it's referenced. No desync possible.
Runtime validation for external inputs:
export function isValidSignalType(key: string): key is SignalTypeKey {
return key in PRODUCT_SIGNAL_TYPES;
}
// Usage
const type = request.query.signalType;
if (!isValidSignalType(type)) {
throw new Error(`Invalid signal type: ${type}`);
}
The signal system is production-ready. Coming soon:
All following the same pattern: derived from state, context-aware, automatically managed.
Your badges should be as dynamic as your inventory. That's what signals deliver.
