By day 64 of your payment engineering journey, you've built checkout flows, handled webhooks, and stored customer data. Now comes the conversion piece: showing users what you offer and letting them choose. Pricing tables and paywalls are the visual bridge between your business model and user decision-making, and getting them right can lift conversion rates by 10–30 percent. This article covers pricing tables and paywalls: what developers should know about embedding Stripe's official solution, rendering your own plans, highlighting winners, and gating features by entitlement.
Stripe pricing table embed
Stripe's pricing table is a prebuilt, hosted component that displays your plans directly on your pricing page. It handles plan logic, currency selection, trial offers, and payment redirection out of the box. For teams shipping fast, this is often the quickest path to production.
To use it, you create a pricing table in the Stripe Dashboard, assign products and prices, configure display options (which price ID to recommend, billing intervals to show), and get back a table ID. Then drop a script and container into your HTML:
// HTML on your pricing page
<stripe-pricing-table id="pricing-table"
pricing-table-id="pt_1234567890"
publishable-key="pk_live_123abc...">
</stripe-pricing-table>
<script async src="https://js.stripe.com/v3/pricing-table.js"></script>Stripe handles the rendering, styling, and checkout flow. Users click "Subscribe" and land directly in a hosted payment form. The table is responsive and follows Stripe's design language, so it works across devices without custom CSS.
The main trade-off: limited customization. You can choose colors and fonts within the Stripe Dashboard UI, but you can't add custom badges, change button text, or inject your brand's exact visual hierarchy. If your pricing page is a hero moment for your product story, you may need more control than the embed provides.
Or render plans from Price objects
When you need full control—custom layouts, animations, A/B testing, feature comparison tables, or tight brand alignment—fetch your prices from the Stripe API and render them yourself in your frontend framework.
First, retrieve all prices for a product (or all products) via the API:
// Node.js backend endpoint
app.get('/api/plans', async (req, res) => {
const prices = await stripe.prices.list({
limit: 100,
active: true,
});
// Group by product, sort by unit_amount
const grouped = {};
for (const price of prices.data) {
if (!grouped[price.product]) {
grouped[price.product] = [];
}
grouped[price.product].push(price);
}
res.json({ prices: prices.data, grouped });
});On your React frontend, fetch and render:
import { useState, useEffect } from 'react';
export default function PricingPage() {
const [prices, setPrices] = useState([]);
useEffect(() => {
fetch('/api/plans').then(r => r.json()).then(d => setPrices(d.prices));
}, []);
return (
<div className="pricing-grid">
{prices.map(price => (
<div key={price.id} className="plan-card">
<h3>{price.nickname || 'Plan'}</h3>
<p className="price">
${(price.unit_amount / 100).toFixed(2)} / {price.recurring?.interval}
</p>
<button onClick={() => handleCheckout(price.id)}>
Subscribe
</button>
</div>
))}
</div>
);
}
async function handleCheckout(priceId) {
const { sessionId } = await fetch('/api/create-checkout', {
method: 'POST',
body: JSON.stringify({ priceId }),
}).then(r => r.json());
const stripe = window.Stripe('pk_...');
stripe.redirectToCheckout({ sessionId });
}This approach lets you store metadata on your Price objects (like feature limits, support tier, or display order) and use that to build rich, custom UIs. You maintain full control over styling, animations, and messaging.
Highlight recommended plans
Psychology matters. Users often gravitate toward the middle option or the one marked "most popular." Your recommended plan should align with your business metrics—usually the plan that maximizes lifetime value, not necessarily the most expensive.
In Stripe, mark a price as recommended via metadata or a custom field, then render it visually differently:
// Store on Price object metadata
const price = await stripe.prices.create({
product: 'prod_123',
unit_amount: 9900,
currency: 'usd',
recurring: { interval: 'month' },
metadata: { recommended: 'true', display_order: '1' },
});
// Frontend: highlight if metadata.recommended === 'true'
{prices.map(price => (
<div
key={price.id}
className={`plan-card ${price.metadata?.recommended ? 'recommended' : ''}`}
>
{price.metadata?.recommended && (
<span className="badge">Most Popular</span>
)}
{/* rest of card */}
</div>
))}Visually, the recommended plan should have a distinct border, shadow, larger buttons, or a slight scale increase to draw the eye without appearing gimmicky. A/B test badge text ("Most Popular" vs. "Best for Teams" vs. "Recommended") to see what resonates with your audience.
Store the recommendation metadata, not hardcode it. This way you can change which plan is highlighted without redeploying your frontend.
Gate features by entitlement
A pricing table means nothing if users can't see why they should upgrade. Feature gating ties plan selection to real product constraints—and if done well, encourages upgrade. If done poorly, it frustrates free users.
After a user signs up or subscribes, you need to know their entitlements: which plan they're on, when their subscription expires, and what features they can access. This lives in your user record and typically gets synced from Stripe via webhooks.
Store a subscription status on your user:
// Database schema
create table users (
id uuid primary key,
email text,
stripe_customer_id text,
subscription_status text, -- 'active', 'trialing', 'past_due', 'canceled'
plan_tier text, -- 'free', 'starter', 'pro', 'enterprise'
features_available jsonb, -- { api_calls: 10000, seats: 3, ... }
created_at timestamptz
);
// On Stripe webhook (customer.subscription.updated, invoice.payment_succeeded)
export async function handleSubscriptionUpdate(subscription) {
const customer = await stripe.customers.retrieve(subscription.customer);
const userId = customer.metadata.user_id;
const priceId = subscription.items.data[0].price.id;
const price = await stripe.prices.retrieve(priceId);
await db.query(
`update users set plan_tier = $1, subscription_status = $2 where id = $3`,
[price.metadata?.tier || 'free', subscription.status, userId]
);
}Then, in your frontend or backend, check entitlements before rendering or allowing features:
// React component
export function APIUsage() {
const { user } = useAuth();
if (user?.plan_tier !== 'pro' && user?.plan_tier !== 'enterprise') {
return (
<div className="feature-locked">
<p>API access is available on Pro and above.</p>
<a href="/pricing">View plans</a>
</div>
);
}
return <APIUsageDashboard />;
}
// Backend: validate entitlements on every API call
app.post('/api/export', authenticateToken, async (req, res) => {
const user = req.user;
if (!['pro', 'enterprise'].includes(user.plan_tier)) {
return res.status(403).json({ error: 'Export requires Pro plan' });
}
// Process export
});Best practice: gate at both frontend and backend. The frontend hides or disables features to guide behavior. The backend enforces rules to prevent cheating or token manipulation. Store feature limits (API rate limits, seat counts, storage) on the user record, and refresh them on every subscription change.
Common mistakes
- Gating too aggressively too early: New free users rarely upgrade if they can't try the core product. Gate advanced features, not core value. Let free users experience enough to understand what they're missing.
- Forgetting to update user entitlements on cancellation: A customer cancels their subscription, but your backend still thinks they're on Pro. Always sync subscription status within seconds of a webhook. Test the cancellation flow in Stripe sandbox mode.
- Hardcoding plan names and limits: When limits or plan names change, you'll have to redeploy. Store everything in Stripe metadata and your database, never in code.
- Not testing the pricing table on mobile: Stripe's embed is responsive, but custom implementations often break on small screens. Test on a real phone, not just DevTools.
- Misaligning recommended plan with business goals: Highlighting the cheapest plan because it's "safest" leaves money on the table. Use data—LTV, churn rate, feature adoption—to pick the recommended tier.
Key takeaways
- Use Stripe's pricing table embed for speed and simplicity; render custom plans from Price objects when you need full design control and feature metadata.
- Store plan tier, subscription status, and feature entitlements in your user database and sync them via Stripe webhooks, not hardcoded logic.
- Gate features at both frontend (UX) and backend (security); never trust client-side permission checks.
- Mark a recommended plan in metadata, not code, so business changes don't require deploys.
- Test cancellation, subscription updates, and trial-to-paid transitions in sandbox mode before shipping to production.






Comments
Loading comments…