A great pricing page can double your conversions without touching your product. In this guide, you’ll build a high-converting pricing page in Next.js with the right UX patterns (monthly/yearly toggle, comparison table, FAQs, social proof) and wire it to Stripe Checkout so people can buy in one click. ⚡
🎯 Principles of pricing pages that convert
- One primary CTA per tier (no decision fatigue).
- Clear value props per plan (3–5 bullets).
- Monthly ↔ Yearly toggle with visible savings (e.g., “Save 20%”).
- Risk reducers: refund policy, cancel anytime, trial messaging.
- Proof: logos, testimonials, usage stats.
- Objection handling: FAQ close to the CTAs.
For inspiration and best practices:
- Pricing UX teardowns: https://www.lennysnewsletter.com/p/pricing-pages
- Stripe pricing model guides: https://stripe.com/pricing#pricing-models
đź§© Page layout (Next.js component)
A clean starter layout with three tiers and a monthly/yearly toggle.
// app/pricing/page.tsx
"use client";
import { useState } from "react";
import Link from "next/link";
type Plan = {
name: string;
monthly: number; // cents
yearly: number; // cents
description: string;
features: string[];
priceIdMonthly?: string; // Stripe price IDs if you have them
priceIdYearly?: string;
highlight?: boolean;
};
const plans: Plan[] = [
{
name: "Starter",
monthly: 990,
yearly: 9900,
description: "Launch your first project in minutes.",
features: ["1 site", "Email support", "Basic analytics"],
priceIdMonthly: "price_monthly_starter",
priceIdYearly: "price_yearly_starter",
},
{
name: "Pro",
monthly: 1990,
yearly: 19900,
description: "Serious features for growing products.",
features: ["3 sites", "Priority support", "Advanced analytics"],
priceIdMonthly: "price_monthly_pro",
priceIdYearly: "price_yearly_pro",
highlight: true,
},
{
name: "Business",
monthly: 4990,
yearly: 49900,
description: "Everything teams need to scale.",
features: ["10 sites", "SLA support", "Team management"],
priceIdMonthly: "price_monthly_business",
priceIdYearly: "price_yearly_business",
},
];
export default function PricingPage() {
const [yearly, setYearly] = useState(true);
return (
<div className="mx-auto max-w-6xl px-6 py-16">
<div className="text-center mb-8">
<p className="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm">
Flexible pricing <span aria-hidden>đź’¸</span>
</p>
<p className="mt-4 text-xl text-neutral-600">
Start free, upgrade when you’re ready. Cancel anytime.
</p>
</div>
{/* Toggle */}
<div className="mb-10 flex items-center justify-center gap-3">
<button
onClick={() => setYearly(false)}
className={`rounded-md px-3 py-1 text-sm ${!yearly ? "bg-black text-white" : "bg-neutral-100"}`}
aria-pressed={!yearly}
>
Monthly
</button>
<button
onClick={() => setYearly(true)}
className={`rounded-md px-3 py-1 text-sm ${yearly ? "bg-black text-white" : "bg-neutral-100"}`}
aria-pressed={yearly}
>
Yearly <span className="ml-1 text-emerald-600">Save 20%</span>
</button>
</div>
{/* Plans */}
<div className="grid gap-6 md:grid-cols-3">
{plans.map((plan) => {
const amount = yearly ? plan.yearly : plan.monthly;
const display = (amount / 100).toLocaleString(undefined, {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
return (
<div
key={plan.name}
className={`rounded-2xl border p-6 ${plan.highlight ? "border-black shadow-lg" : "border-neutral-200"}`}
>
<h2 className="text-lg font-semibold">{plan.name}</h2>
<p className="mt-1 text-sm text-neutral-600">{plan.description}</p>
<div className="mt-6">
<div className="flex items-end gap-2">
<span className="text-4xl font-bold">{display}</span>
<span className="text-neutral-500">{yearly ? "/year" : "/month"}</span>
</div>
</div>
<ul className="mt-6 space-y-2 text-sm">
{plan.features.map((f) => (
<li key={f} className="flex items-start gap-2">
<span aria-hidden>âś…</span>
<span>{f}</span>
</li>
))}
</ul>
<form
className="mt-6"
action="/api/create-checkout-session"
method="POST"
>
<input
type="hidden"
name="priceId"
value={yearly ? plan.priceIdYearly : plan.priceIdMonthly}
/>
<button
className={`w-full rounded-md px-4 py-2 text-sm font-medium ${plan.highlight ? "bg-black text-white" : "bg-neutral-900 text-white"}`}
>
Get {plan.name}
</button>
</form>
<p className="mt-3 text-xs text-neutral-500">
14-day refund • Cancel anytime • No hidden fees
</p>
</div>
);
})}
</div>
{/* Proof & FAQ */}
<div className="mt-16 grid gap-12 md:grid-cols-2">
<section aria-labelledby="proof">
<h3 id="proof" className="text-base font-semibold">Trusted by builders</h3>
<p className="mt-2 text-sm text-neutral-600">
Join startups and indie makers launching products in hours, not weeks. <span aria-hidden>🚀</span>
</p>
<ul className="mt-4 grid grid-cols-2 gap-3 text-sm text-neutral-700">
<li>• 1,000+ deployments</li>
<li>• 99.9% uptime</li>
<li>• <Link className="underline" href="/blog">Read success stories</Link></li>
<li>• <a className="underline" href="https://vercel.com/templates/next.js" target="_blank" rel="noreferrer">Vercel templates</a></li>
</ul>
</section>
<section aria-labelledby="faq">
<h3 id="faq" className="text-base font-semibold">FAQ</h3>
<details className="mt-3 rounded-md border p-3">
<summary className="cursor-pointer select-none">Can I switch plans later?</summary>
<p className="mt-2 text-sm text-neutral-600">Yes, upgrades or downgrades take effect on your next billing cycle.</p>
</details>
<details className="mt-3 rounded-md border p-3">
<summary className="cursor-pointer select-none">Do you offer refunds?</summary>
<p className="mt-2 text-sm text-neutral-600">We offer a 14-day refund window. Just reach out if something’s not right.</p>
</details>
<details className="mt-3 rounded-md border p-3">
<summary className="cursor-pointer select-none">Is my payment secure?</summary>
<p className="mt-2 text-sm text-neutral-600">
All payments are handled by <a className="underline" href="https://stripe.com/docs/security/stripe" target="_blank" rel="noreferrer">Stripe</a> PCI-compliant and industry-standard security.
</p>
</details>
</section>
</div>
</div>
);
}
This client page renders the toggle, tiers, and a native form per tier that posts a priceId to your server route.
🔌 Hook up Stripe Checkout (server route)
A minimal server route that reads the priceId from the form post and creates a Checkout Session.
// app/api/create-checkout-session/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-04-30.basil",
});
export async function POST(req: NextRequest) {
const form = await req.formData();
const priceId = form.get("priceId") as string | null;
if (!priceId) {
return NextResponse.json({ error: "Missing priceId" }, { status: 400 });
}
try {
const session = await stripe.checkout.sessions.create({
mode: "subscription", // or "payment" for one-time
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
allow_promotion_codes: true,
});
return NextResponse.redirect(session.url!, { status: 303 });
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to create checkout session" }, { status: 500 });
}
}
If you sell one-time licenses, switch mode: "payment" and use one-time Price IDs.
Stripe references:
- Checkout Sessions: https://stripe.com/docs/api/checkout/sessions/create
- Subscription vs one-time pricing: https://stripe.com/docs/billing/prices-guide
đź§ Copy & UX tips that lift conversions
- Emphasize outcomes (“Launch in hours”) not features.
- Use microcopy under the button: “14-day refund • Cancel anytime”.
- Show the most popular plan (visual highlight).
- Keep three tiers max to reduce analysis paralysis.
- Add FAQs near the CTAs to resolve objections immediately.
Great UX primers:
- NNg clarity first: https://www.nngroup.com/articles/ux-guidelines/
- Copy tips for pricing: https://www.appcues.com/blog/pricing-page-examples
✅ Final checklist ⚙️
- [ ] Stripe price IDs wired to each tier
- [ ] Toggle works and shows real savings
- [ ] One primary button per plan (no dead ends)
- [ ] Refund/cancel policy stated near CTAs
- [ ] Social proof and FAQs present
- [ ] Success & cancel routes exist and tested
- [ ] Metadata set for
/pricing(title, description, canonical, OG)
With this setup, your pricing page is fast, clear, and persuasive. It connects directly to Stripe so visitors can become customers in one click. đź›’
