We rewrote components/pricing-cards.tsx three times in six months. Same product, same Stripe checkout, same three license tiers (single | multiple | enterprise). What changed was the component. The conversion went from ~1.1% to ~3.4%.
This post is the actual diffs and the actual numbers. No theory, no funnel-graph PNGs.
The product
Applighter sells full-stack React Native + Expo templates. Each template has three license tiers stored as rows in a Supabase product_licenses table:
license_type text check (license_type in ('single','multiple','enterprise'))
price_usd numeric not null
allowed_users int
allowed_projects int
is_commercial boolean
Tiers are correct. The component rendering them was wrong three times.
Version 1: textbook, ~1.1%
{licenses.map(license => (
<Card>
<CardTitle>{license.license_type}</CardTitle>
<div className="text-4xl">${license.price_usd.toFixed(2)}</div>
<ul>{allFeatures.map(f => <li>{f}</li>)}</ul>
<BuyButton />
</Card>
))}
Three identical cards. Long feature matrix. .toFixed(2) on every price for "safety." A weak border on the middle card that wasn't visible in dark mode.
What the data said:
- 38% of sessions ended without a single
pricing_card_focusevent - Time spent disproportionately on the cheapest card
- The
multipletier — best per-developer economics — got the least attention
Classic decision-paralysis. Three identical options read as a comparison spreadsheet, not a recommendation.
Version 2: anchor the middle tier, ~1.9%
The fix was one boolean:
function getLicenseHighlight(type: string): boolean {
return type === "multiple";
}
Wired into the card:
<Card className={isHighlighted ? "border-primary shadow-lg" : ""}>
{isHighlighted && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2">
Most Popular
</Badge>
)}
...
</Card>
And reordered columns left-to-right by price: cheapest, recommended, premium. Buyer's eye now lands on the middle tier first; the others become reference points.
Result: ~1.1% → ~1.9% over four weeks. Almost double from one boolean and a sort.
Version 3: small fixes, ~3.4%
Fix 1: drop trailing .00
The one-liner that lifted conversion the most:
<span className="text-4xl font-bold">
${Number(license.price_usd) % 1 === 0
? Number(license.price_usd)
: Number(license.price_usd).toFixed(2)}
</span>
If the price is a whole dollar, render the integer. Otherwise two decimals. $49.00 → $49.
This change, isolated for two weeks holding everything else constant, accounted for roughly half the v2→v3 lift. The most embarrassing finding in our analytics this year.
Fix 2: replace the feature matrix with six bullets per card
<ul className="space-y-3">
<li><Check /> {license.allowed_users
? `Up to ${license.allowed_users} developers`
: "Unlimited developers"}</li>
<li><Check /> {license.allowed_projects
? `${license.allowed_projects} projects`
: "Unlimited projects"}</li>
<li><Check /> {license.is_commercial
? "Commercial use included"
: "Personal use"}</li>
{features.slice(0, 3).map(f => <li><Check /> {f}</li>)}
</ul>
Three claims from the license row, three from the product. Six bullets per card. The diff between Single and Team becomes immediately readable: one developer, one project vs. up to N developers, N projects. That's the comparison the buyer is making.
Fix 3: refund/ownership line under the CTA
<CardFooter>
<BuyButton ... />
<p className="text-xs text-muted-foreground mt-3">
7-day refund · lifetime updates · own the code
</p>
</CardFooter>
The last objection happens at the click. The answer has to live there.
Side-by-side
| Version | Change | Conversion |
|---|---|---|
| V1 | Identical cards, feature matrix, .toFixed(2)
|
~1.1% |
| V2 | "Most Popular" badge on multiple, price-sorted |
~1.9% |
| V3 | Drop .00, 6 bullets, refund line under CTA |
~3.4% |
The actual lesson for devs
Most of what helped wasn't pricing strategy. It was deleting fussiness:
- Layout fussiness → anchor a tier
- Content fussiness → cut to 6 bullets
- Formatting fussiness → drop trailing zeros
Three rewrites, three layers peeled. Ship them in order, not as one A/B test. Watch each for two weeks.
What to copy tonight
// 1. Anchor the middle tier
const isHighlighted = license.license_type === "multiple";
// 2. Smart price rendering
const price = Number(license.price_usd);
const display = price % 1 === 0 ? price : price.toFixed(2);
// 3. Refund line lives by the CTA, not the page footer
<BuyButton />
<p className="text-xs">7-day refund · lifetime updates</p>
That's the diff. The component is in components/pricing-cards.tsx. The rest of the engineering — Supabase rows, Stripe Checkout, Edge Function license grants — didn't change once across all three rewrites.
For more on the React Native + Expo stack behind this, the Expo docs on EAS Build and Stripe's pricing experiments guide are the two external reads worth the time.
What's the smallest pricing-page change that moved a real number for you? Drop the diff in the comments — I'm collecting examples from indie devs and small SaaS teams.












