Pricing pages are easy to make look right and easy to get wrong underneath. The visual output will be excellent on the first try. The logic — what happens when an existing customer clicks a different plan — will be missing entirely.
This assumes you’ve read vibe code Stripe checkout, which covers the payment plumbing.
Get the Prices From Stripe
Hardcoded prices drift. You run a promotion, change a tier, adjust for a currency — and now the page says one number while the customer is charged another.
Fetch the pricing plans from the Stripe API rather than hardcoding them. Read active products and their prices, cache the result for one hour, and render from that. Include both monthly and annual price IDs per tier.
This also means adding a plan in the Stripe dashboard makes it appear on your site with no deploy.
The Display
Build a pricing page with three tiers side by side. Each shows: name, price, billing period, a short description, a feature list with check icons, and a CTA button. Highlight the middle tier as recommended.
Add a monthly/annual toggle. When annual is selected, show the monthly equivalent with “billed annually at $X” underneath, plus the percentage saved.
Below the tiers: a feature comparison table and an FAQ with 6 questions. Responsive — tiers stack on mobile with the recommended one first.
That mobile detail matters. Stacked tiers default to the cheapest first, which buries the plan you want people to pick.
The Part That’s Always Missing
Ask for the display and you’ll get a page where every button says “Get Started” — including for someone who already subscribes.
The CTA on each tier must depend on the viewer’s state:
- Logged out → “Start free trial”, goes to signup
- Logged in, no subscription → “Subscribe”, starts checkout
- Logged in, on this plan → “Current plan”, disabled
- Logged in, on a cheaper plan → “Upgrade”
- Logged in, on a more expensive plan → “Downgrade”
Read the current plan server-side from our database, not from client state.
This is the difference between a picture of a pricing page and a working one.
Upgrades and Downgrades
Two different behaviours, and AI usually implements neither — it just creates a second subscription, so the customer pays twice.
Upgrades take effect immediately using Stripe’s subscription update with proration_behavior set to ‘create_prorations’. Charge the prorated difference now.
Downgrades take effect at period end using a scheduled subscription update. Do not refund the current period. Show the user when the change will apply.
Never create a second subscription. Always modify the existing one.
Then show the pending state:
If a downgrade is scheduled, show a banner: “Your plan changes to X on [date]” with an option to cancel the change.
Feature Gating
The pricing page promises limits. Something has to enforce them.
Create a server-side
canAccess(userId, feature)helper that reads the user’s plan from the database and checks it against a plan-limits config. Use it on every gated route and API endpoint — not just to hide UI.When a limit is hit, return a clear error naming the limit and linking to upgrade.
Gating that only hides buttons is not gating. The endpoint still works.
For usage-based limits:
Track usage counters per user per billing period. Reset them on the subscription renewal webhook, not on a calendar month boundary.
Calendar-month resets don’t line up with billing periods, and the mismatch produces customers who lose access days before renewal.
Trials and Cancellation
Support a 14-day trial with no card required. Track trial_ends_at. Send reminder emails at 7 days, 3 days, and 1 day remaining. On expiry without payment, downgrade to a read-only free tier rather than deleting anything.
Add cancellation in account settings. Cancel at period end, not immediately. Show the exact date access ends. Offer a downgrade to free as an alternative before confirming.
Deleting data at trial end generates furious support tickets. Read-only is the humane default.
The Checklist
- Prices read from Stripe, cached
- CTA reflects the viewer’s actual subscription state
- Upgrade prorates immediately
- Downgrade schedules for period end
- Never creates a second subscription
- Pending plan changes shown with a cancel option
- Feature gating enforced server-side, not just in UI
- Usage counters reset on the billing webhook
- Annual pricing states the billed total clearly
- Cancellation at period end with the date shown
- Mobile stacks with the recommended tier first
Test the Awkward Paths
The happy path always works. These are the ones that break:
- Subscribe monthly, upgrade to annual mid-period — is the proration right?
- Downgrade, then cancel the downgrade before it applies
- Cancel, then resubscribe before period end
- Let a trial expire without paying — is it read-only, not deleted?
- Hit a plan limit — is the error clear and the upgrade link correct?
Walk all five in Stripe test mode before launch.
Next: Vibe code a SaaS MVP puts this in context, or revisit Stripe checkout for the webhook that makes any of it real.
Related reading: