Command Menu

Litestore
ShopCategoriesCollections
/Blog
/Product Signals: Badges That Know Where They Are

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.

Jan 29, 2025•7 min read

You'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:

  1. Allowed in this context
  2. Not suppressed
  3. Eligible based on product state
  4. 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:

  1. AI-enhanced signals — "Similar items selling fast" based on category trends
  2. Personalized signals — "You viewed this 3 times" for logged-in users
  3. Price drop signals — "Price dropped 15% since you last viewed"
  4. 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.

Share:
Written by
Fabian Likam's profile

Fabian Likam

@fabianlikam
Shop
All ProductsNew ArrivalsNewBest SellersSale
Help
Contact UsFAQShippingReturns
Company
About UsBlogCareersPress
Connect
InstagramTwitterTiktokYoutube
Privacy PolicyTerms of Service
  • Visa
  • Mastercard
  • American Express
  • PayPal
  • Apple Pay
  • Google Pay
Secure Checkout

© 2026 Litestore. All rights reserved.

HomeCartWishlistAccount