Product Signals: Badges That Know Where They Are
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.
Signals Are Derived, Not Stored
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.
The Seven Signal Types
1. Discount
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.
2. Low Stock
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.
3. Limited Edition
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.
4. Trending
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.
5. Top Rated
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."
6. New Arrival
Eligibility: Published within last 14 days Display: "New" Priority: 5
Time-based, automatic. After 14 days, signal disappears. No cleanup needed.
7. Popular
Eligibility: Combined likes + wishlists ≥ 50 Display: "Popular — 200+ saves" Priority: 4
Engagement-driven. Real user interest, not editorial picks.
Signal Contexts: The Real Innovation
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:
- Which signal types are allowed
- Which are suppressed
- How many signals per product (maxSignals)
- Coherence mode for multi-product views
Product Page: Show Everything Relevant
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/Feed: Prioritize Diversity
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 Collection: Only Discount Signals
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: Urgency Only
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: Minimal Distraction
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.
How Signal Resolution Works
// 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:
- Allowed in this context
- Not suppressed
- Eligible based on product state
- Within the max signal limit
Coherence Orchestration
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.
Adding a New Signal Type
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.
Real-Life Impact
Before Signals
- Marketing manually tagged 800 products as "bestseller" in 2023
- 200 of those products are now discontinued
- Every product page shows "BESTSELLER" even for items with zero sales
- Customers don't trust badges anymore
After Signals
- "Trending" requires actual sales in the last 3 days
- Discontinued products show no signals (not eligible)
- Badges are always accuration because they're derived from state
- Customer trust in badges = higher conversion
The Feed Feed
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.
Technical Details
TTL Hints
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.
Type Safety
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.
Validation
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}`);
}
What's Next
The signal system is production-ready. Coming soon:
- AI-enhanced signals — "Similar items selling fast" based on category trends
- Personalized signals — "You viewed this 3 times" for logged-in users
- Price drop signals — "Price dropped 15% since you last viewed"
- Restock notifications — "Back in stock" with notification tie-in
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.
