Skip to main content
ship before you scale

Usage Limit UX Patterns and Upgrade Psychology

5 min read Chapter 39 of 42

Usage Limit UX Patterns and Upgrade Psychology

The Feature

Usage limits feel transparent and fair. The organizer always knows where they stand relative to their limits. Upgrade prompts appear at the moment of maximum motivation (when the user wants to do something the free tier does not allow) and provide clear information about what the upgrade includes. The free tier never feels crippled. It feels complete for its intended use case.

The Decision

Three principles govern the upgrade experience:

  1. Inform before restrict. Show usage levels continuously, not just when limits are hit. The organizer should never be surprised by a limit.
  2. Restrict the action, not the account. When a limit is reached, the specific action is blocked. Everything else continues working. The organizer can still manage existing vendors, view reports, and run market days.
  3. Explain the value, not the restriction. The upgrade prompt says “Upgrade to manage up to 200 vendors” not “You have been blocked from adding vendors.”

The Implementation

Progressive Limit Indicators

// frontend/src/components/VendorList.tsx
import { VendorLimitBanner } from "./VendorLimitBanner";
import { Button } from "@/components/ui/button";

interface VendorListProps {
  vendors: Vendor[];
  vendorCount: number;
  maxVendors: number;
  tier: string;
  onAddVendor: () => void;
}

export function VendorList({
  vendors,
  vendorCount,
  maxVendors,
  tier,
  onAddVendor,
}: VendorListProps) {
  const canAddVendor = vendorCount < maxVendors || tier !== "free";

  return (
    <div>
      <VendorLimitBanner
        vendorCount={vendorCount}
        maxVendors={maxVendors}
        tier={tier}
      />

      <div className="flex items-center justify-between mb-4">
        <h2 className="text-xl font-semibold">
          Vendors ({vendorCount}/{maxVendors})
        </h2>

        {canAddVendor ? (
          <Button onClick={onAddVendor}>Add Vendor</Button>
        ) : (
          <Button asChild>
            <a href="/settings/billing?source=vendor_limit">
              Upgrade to Add More
            </a>
          </Button>
        )}
      </div>

      {/* Vendor table */}
      <div className="space-y-2">
        {vendors.map((vendor) => (
          <VendorRow key={vendor.id} vendor={vendor} />
        ))}
      </div>
    </div>
  );
}

Tier Comparison Page

// frontend/src/pages/Pricing.tsx

const features = [
  {
    name: "Vendors per market",
    free: "Up to 20",
    growth: "Up to 200",
  },
  {
    name: "Markets",
    free: "2",
    growth: "10",
  },
  {
    name: "Document storage",
    free: "500 MB",
    growth: "5 GB",
  },
  {
    name: "Vendor applications",
    free: "Included",
    growth: "Included",
  },
  {
    name: "Market day management",
    free: "Included",
    growth: "Included",
  },
  {
    name: "Stall assignments",
    free: "Included",
    growth: "Included",
  },
  {
    name: "Email notifications",
    free: "Included",
    growth: "Included",
  },
  {
    name: "Custom branding",
    free: "No",
    growth: "Yes",
  },
  {
    name: "Priority support",
    free: "Community",
    growth: "Email support",
  },
];

export function PricingPage() {
  return (
    <div className="max-w-4xl mx-auto py-12 px-4">
      <h1 className="text-3xl font-bold text-center mb-8">
        Simple, transparent pricing
      </h1>
      <p className="text-center text-gray-600 dark:text-gray-400 mb-12">
        Free for small markets. Affordable for growing ones.
      </p>

      <div className="grid md:grid-cols-2 gap-8">
        {/* Free Tier */}
        <div className="rounded-lg border p-6">
          <h2 className="text-xl font-semibold">Free</h2>
          <p className="text-3xl font-bold mt-2">$0<span className="text-base font-normal text-gray-500">/month</span></p>
          <p className="text-gray-600 dark:text-gray-400 mt-2">
            Perfect for small neighborhood markets
          </p>
          <ul className="mt-6 space-y-3">
            {features.map((f) => (
              <li key={f.name} className="flex justify-between text-sm">
                <span>{f.name}</span>
                <span className="font-medium">{f.free}</span>
              </li>
            ))}
          </ul>
        </div>

        {/* Growth Tier */}
        <div className="rounded-lg border-2 border-blue-500 p-6 relative">
          <span className="absolute -top-3 left-4 bg-blue-500 text-white text-xs px-2 py-1 rounded">
            Most Popular
          </span>
          <h2 className="text-xl font-semibold">Growth</h2>
          <p className="text-3xl font-bold mt-2">$29<span className="text-base font-normal text-gray-500">/month</span></p>
          <p className="text-gray-600 dark:text-gray-400 mt-2">
            For established markets ready to grow
          </p>
          <ul className="mt-6 space-y-3">
            {features.map((f) => (
              <li key={f.name} className="flex justify-between text-sm">
                <span>{f.name}</span>
                <span className="font-medium text-blue-600 dark:text-blue-400">{f.growth}</span>
              </li>
            ))}
          </ul>
          <Button className="w-full mt-6" asChild>
            <a href="/settings/billing?source=pricing_page">
              Start Growth Plan
            </a>
          </Button>
        </div>
      </div>
    </div>
  );
}

Limit Hit Error Handling on Frontend

// frontend/src/api/errorHandling.ts
import { toast } from "@/components/ui/toast";

export function handleApiError(error: unknown) {
  if (error instanceof Response || (error as any)?.status === 403) {
    const detail = (error as any)?.detail;

    if (detail?.error === "usage_limit_reached") {
      toast({
        title: `${capitalize(detail.limit_type)} limit reached`,
        description: `You are using ${detail.current} of ${detail.maximum} ${detail.limit_type}. Upgrade to Growth for higher limits.`,
        action: {
          label: "Upgrade",
          href: detail.upgrade_url,
        },
      });
      return;
    }
  }

  // Default error handling
  toast({
    title: "Something went wrong",
    description: "Please try again or contact support.",
    variant: "destructive",
  });
}

function capitalize(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

Upgrade Source Tracking in the Frontend

// Every upgrade link includes a source parameter
// This maps to the upgrade_source metadata in Stripe

// From the vendor limit banner:
<a href="/settings/billing?source=vendor_limit_banner">

// From the add vendor button replacement:
<a href="/settings/billing?source=vendor_limit_button">

// From the pricing page:
<a href="/settings/billing?source=pricing_page">

// From the usage dashboard:
<a href="/settings/billing?source=usage_dashboard">

// The billing page reads this parameter and passes it to the
// Stripe Checkout session creation endpoint:
const source = new URLSearchParams(window.location.search).get("source") || "billing_page";

The Trap

// TRAP: Hiding features behind the paywall without explanation
// The "Custom Branding" option is grayed out with no tooltip
// The user does not know it exists or what it does
// When they upgrade, they do not discover it
// It provides zero conversion motivation

// SAFE: Show the feature, explain it, and show what it looks like on the paid tier
<div className="relative">
  <div className="opacity-50 pointer-events-none">
    <BrandingEditor />
  </div>
  <div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-900/80 rounded">
    <div className="text-center p-4">
      <p className="font-medium">Custom branding is available on Growth</p>
      <p className="text-sm text-gray-500 mt-1">
        Add your market logo, colors, and custom domain
      </p>
      <Button size="sm" className="mt-3" asChild>
        <a href="/settings/billing?source=branding_upsell">See plans</a>
      </Button>
    </div>
  </div>
</div>

The most effective upgrade motivator is showing the user what they are missing, not telling them. A grayed-out feature with a preview is more compelling than a bullet point on a pricing page. The user can see the value before they pay for it.

The Cost

The entire freemium implementation runs on existing infrastructure. No additional services, no product analytics tools, no experimentation platforms. Usage limits are database queries. Conversion tracking is Stripe metadata plus a PostgreSQL table. Upgrade prompts are React components. The cost is zero beyond developer time.