Build a billing page in Next.js with Stripe
Build a SaaS billing page in Next.js with Stripe: plans, usage, invoices, payment methods and cancellation, loaded on the server and kept in sync by webhooks.
By MiniDev17 min read
A billing page in Next.js with Stripe is a server component that reads the customer's subscription from your database, fetches invoices and payment methods with the Stripe Node SDK, and renders them with a few presentational components. Plan changes go through Stripe Checkout or a subscription update in a server action, card updates can hand off to the Stripe Customer Portal, and webhooks keep your database in step with Stripe. MiniDev UI provides the billing components as free, shadcn-compatible React files, so the work left is the data wiring this guide covers.
What a billing page has to answer
People open the billing page with a question, usually one of these. Design the page so each answer is visible without clicking:
- What am I paying for, and when is the next charge? Current plan, price, and renewal or cancellation date.
- How close am I to my limits? Seats, projects, API calls or credits, against the plan's allowance.
- Which card is on file? Brand, last four digits, expiry, and a way to change it.
- What did I pay? A list of invoices with status and a link to the PDF.
- How do I change or cancel? Upgrade, downgrade and cancel, each with its consequences stated up front.
Customer Portal or your own page
Stripe's hosted Customer Portal already handles card updates, invoice history, plan switching and cancellation, including authentication challenges on card changes. It is the fastest route and a fine default. A custom page is worth it when billing should look like the rest of your product, when usage and entitlements live in your database, or when you want a considered cancel flow. The practical answer for most SaaS apps is a hybrid:
| Task | Custom page | Hand off to Stripe |
|---|---|---|
| Show plan, usage, credits | Yes, from your database | No |
| List invoices | Yes, stripe.invoices.list | PDF and hosted invoice links |
| New subscription | Plan cards | Checkout Session |
| Change plan | Server action with subscriptions.update | Optional |
| Update card | Show the current card | Portal session with payment_method_update flow |
| Cancel | Your cancel flow | Optional portal subscription_cancel flow |
Install the components
npx shadcn@latest add https://ui.minidev.pro/r/plan-card.json https://ui.minidev.pro/r/usage-meter.json https://ui.minidev.pro/r/invoice-list.json
npx shadcn@latest add https://ui.minidev.pro/r/payment-method-card.json https://ui.minidev.pro/r/credit-balance.json https://ui.minidev.pro/r/cancel-flow.json https://ui.minidev.pro/r/downgrade-warning.json
npm i stripe
Add the token stylesheet once so classes like bg-surface and text-fg-muted resolve. Know what you are installing: PlanCard, UsageMeter, InvoiceList, PaymentMethodCard and CreditBalance take data as props. CancelFlow and DowngradeWarning are designed layouts with sample copy and unwired buttons, and the BillingPage block takes no props at all. The registry copies source into your project, so you edit those files to add your text and handlers.
Stripe is the source of truth, your database is the cache
Do not call Stripe on every request to decide what a user may do. Store the few fields your app checks (subscription id, status, price id, period end, cancel_at_period_end) on the account row, write them from webhooks, and read them like any other data. Keep the Stripe client in a server-only module so the secret key can never be bundled for the browser:
import "server-only"
import Stripe from "stripe"
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function syncSubscription(customerId: string) {
const { data } = await stripe.subscriptions.list({ customer: customerId, status: "all", limit: 1 })
const sub = data[0]
const item = sub?.items.data[0]
await db.account.update({
where: { stripeCustomerId: customerId },
data: sub
? {
subscriptionId: sub.id,
status: sub.status,
priceId: item?.price.id ?? null,
periodEnd: item ? new Date(item.current_period_end * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
}
: { subscriptionId: null, status: "none", priceId: null, periodEnd: null, cancelAtPeriodEnd: false },
})
}
Pin an API version in the Stripe Dashboard and upgrade deliberately. On recent API versions the billing period fields (current_period_start, current_period_end) live on each subscription item rather than on the subscription, which is why the code reads them from items.data[0].
Webhooks keep state in sync
A route handler receives events, verifies the signature against the raw body, and re-syncs the customer. Re-fetching the subscription instead of trusting the event payload makes the handler safe against events that arrive out of order or more than once.
import type Stripe from "stripe"
import { stripe, syncSubscription } from "@/lib/stripe"
const RELEVANT = new Set<string>([
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.paid",
"invoice.payment_failed",
])
export async function POST(req: Request) {
const body = await req.text()
const signature = req.headers.get("stripe-signature")
if (!signature) return new Response("Missing signature", { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response("Invalid signature", { status: 400 })
}
if (RELEVANT.has(event.type)) {
const obj = event.data.object as { customer?: string | { id: string } | null }
const customerId = typeof obj.customer === "string" ? obj.customer : obj.customer?.id
if (customerId) await syncSubscription(customerId)
}
return new Response(null, { status: 200 })
}
Read the body with req.text() and pass that exact string to constructEvent. Parsing JSON first changes the bytes and every signature check fails. Return a 2xx quickly; Stripe retries failed deliveries, so slow work belongs in a queue. Locally, stripe listen --forward-to localhost:3000/api/stripe/webhook prints a signing secret for testing.
Load billing data on the server
The page is an async server component. Plan state and usage come from your database; invoices, cards and the credit balance come from Stripe in parallel. Map everything to the plain strings the components expect before it crosses into client components.
import { stripe } from "@/lib/stripe"
import { UsageMeter } from "@/components/ui/usage-meter"
import { InvoiceList } from "@/components/ui/invoice-list"
import { PaymentMethodCard } from "@/components/ui/payment-method-card"
import { CreditBalance } from "@/components/ui/credit-balance"
import { PlanGrid } from "./plan-grid"
const date = (s: number) => new Date(s * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
const money = (cents: number, currency: string) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(cents / 100)
export default async function BillingRoute() {
const account = await requireAccount()
const customer = account.stripeCustomerId
const [usage, invoices, cards, stripeCustomer] = await Promise.all([
getUsage(account.id),
stripe.invoices.list({ customer, limit: 12 }),
stripe.paymentMethods.list({ customer, type: "card" }),
stripe.customers.retrieve(customer),
])
const rows = invoices.data
.filter((i) => i.status !== "draft")
.map((i) => ({
id: i.number ?? i.id,
date: date(i.created),
amount: money(i.total, i.currency),
status: i.status === "paid" ? ("paid" as const) : i.status === "void" ? ("void" as const) : ("open" as const),
}))
const card = cards.data[0]?.card
const credit = stripeCustomer.deleted ? 0 : -stripeCustomer.balance
const currency = stripeCustomer.deleted ? "usd" : stripeCustomer.currency ?? "usd"
return (
<div className="space-y-8">
<PlanGrid currentPriceId={account.priceId} />
<div className="grid gap-4 rounded-xl border border-border bg-surface p-4 sm:grid-cols-2">
<UsageMeter label="Seats" used={usage.seats} limit={usage.seatLimit} />
<UsageMeter label="Projects" used={usage.projects} limit={usage.projectLimit} />
</div>
{credit > 0 ? <CreditBalance label="Account credit" balance={money(credit, currency)} /> : null}
{card ? (
<PaymentMethodCard
brand={card.brand.charAt(0).toUpperCase() + card.brand.slice(1)}
last4={card.last4}
exp={String(card.exp_month).padStart(2, "0") + "/" + String(card.exp_year).slice(-2)}
/>
) : null}
<InvoiceList items={rows} />
</div>
)
}
- Stripe amounts are integers in the smallest currency unit. Dividing by 100 is right for USD and EUR but not for zero-decimal currencies such as JPY, so use a helper that knows the difference if you bill in several currencies.
- A negative
customer.balanceis credit that Stripe applies to the next invoice. Flip the sign before showing it inCreditBalance. InvoiceListaccepts three statuses:paid,openandvoid. Mapuncollectibletoopen(it renders with the warning tone) and skip drafts, which customers should not see.InvoiceListrenders the invoice number as plain text. Edit your copy to wrap it in a link tohosted_invoice_urlorinvoice_pdf, which Stripe returns on each invoice.UsageMetershows the rawused/limitnumbers and caps the bar at 100%. Show an over-limit state yourself if you allow overage.
Plans and upgrades
A customer without a subscription goes to Checkout. A customer with one gets their existing subscription updated; creating a second subscription is the classic double-billing bug. Both paths live in one server action. Server action arguments come from the client, so check the price id against your own list:
"use server"
import { redirect } from "next/navigation"
import { revalidatePath } from "next/cache"
import { stripe, syncSubscription } from "@/lib/stripe"
import { PLANS } from "@/lib/plans"
export async function changePlan(priceId: string) {
const account = await requireAccount()
if (!PLANS.some((p) => p.priceId === priceId)) throw new Error("Unknown plan")
if (!account.subscriptionId || account.status === "canceled") {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: account.stripeCustomerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: process.env.APP_URL + "/billing?checkout=success",
cancel_url: process.env.APP_URL + "/billing",
})
redirect(session.url!)
}
const sub = await stripe.subscriptions.retrieve(account.subscriptionId)
await stripe.subscriptions.update(sub.id, {
items: [{ id: sub.items.data[0].id, price: priceId }],
proration_behavior: "create_prorations",
})
await syncSubscription(account.stripeCustomerId)
revalidatePath("/billing")
}
PlanCard is a client component with an onCta callback, so render the grid from a small client wrapper and call the action inside a transition. The card has no disabled prop, so guard the current plan in the handler and say so in the cta text:
"use client"
import { useTransition } from "react"
import { PlanCard } from "@/components/ui/plan-card"
import { PLANS } from "@/lib/plans"
import { changePlan } from "./actions"
export function PlanGrid({ currentPriceId }: { currentPriceId: string | null }) {
const [pending, startTransition] = useTransition()
return (
<div className="grid gap-4 lg:grid-cols-3" aria-busy={pending}>
{PLANS.map((p) => {
const current = p.priceId === currentPriceId
return (
<PlanCard
key={p.priceId}
name={p.name}
price={p.price}
features={p.features}
highlighted={current}
cta={current ? "Current plan" : "Switch to " + p.name}
onCta={() => {
if (current || pending) return
startTransition(() => changePlan(p.priceId))
}}
/>
)
})}
</div>
)
}
Downgrades deserve a pause. Show DowngradeWarning first, rewritten in your copy to name what the customer will lose with real numbers ("You have 18 seats; Free includes 3"). If a downgrade should take effect at renewal rather than immediately, use a subscription schedule, or set proration_behavior: "none" when you do not want to credit the unused time. The pricing page guide covers the public plan grid that feeds the same PLANS list.
Card updates through the Customer Portal
Collecting card details yourself means Stripe Elements, SetupIntents and authentication challenges. A portal session with a flow skips all of it: the customer lands directly on the update-card screen and returns to your page when done.
export async function updateCard() {
const account = await requireAccount()
const session = await stripe.billingPortal.sessions.create({
customer: account.stripeCustomerId,
return_url: process.env.APP_URL + "/billing",
flow_data: { type: "payment_method_update" },
})
redirect(session.url)
}
PaymentMethodCard renders an Edit button with no handler, so add an action or onEdit prop to your copy, or wrap the card in <form action={updateCard}> and change the button to type="submit". Enable the payment method update feature in the portal settings in the Stripe Dashboard first.
A cancel flow that keeps trust
CancelFlow is two steps: a reason, then a confirmation that states what happens and when. Wire the reason radios to Stripe's cancellation_details.feedback values and cancel at the period end, so the customer keeps what they paid for:
type Feedback = "too_expensive" | "unused" | "switched_service" | "missing_features" | "other"
export async function cancelSubscription(feedback: Feedback, comment?: string) {
const account = await requireAccount()
await stripe.subscriptions.update(account.subscriptionId!, {
cancel_at_period_end: true,
cancellation_details: { feedback, comment },
})
await syncSubscription(account.stripeCustomerId)
revalidatePath("/billing")
}
export async function resumeSubscription() {
const account = await requireAccount()
await stripe.subscriptions.update(account.subscriptionId!, { cancel_at_period_end: false })
await syncSubscription(account.stripeCustomerId)
revalidatePath("/billing")
}
- Put the cancel entry point on the billing page where people look for it, and make it as easy as upgrading. Hidden or multi-screen cancellation generates chargebacks and support tickets.
- After canceling, show "Your plan ends on Oct 31" with a Resume button that calls
resumeSubscription. A Banner at the top of the app works well for this. - One optional offer (a pause or a discount) is fine. A maze of offers is not.
Confirm the result with a toast after the action returns, and show errors inline next to the button that failed. The toast notifications guide covers the pattern for server actions.
Components used
Everything here is free under MIT and lives in the billing category: billing-page, plan-card, usage-meter, invoice-list, payment-method-card, credit-balance, cancel-flow and downgrade-warning. For the page around them, the SaaS dashboard guide covers the app shell and the settings page guide covers account and team settings. If you would rather have the whole billing system built and tested, the MiniDev studio builds complete products on this kit.
Plan CarduiA pricing plan card with the plan name, a large price per period, a feature list and a full width call to action. A highlighted prop marks the recommended tier.npx shadcn@latest add ui.minidev.pro/r/plan-card.jsonInvoice ListuiA bordered list of invoices showing number, date, amount and a paid, open or void status badge on each row. Fits the billing history in account settings.npx shadcn@latest add ui.minidev.pro/r/invoice-list.jsonCancel FlowuiTwo step subscription cancellation flow: ask for a reason with radio options, then confirm with a destructive button or go back. Tells users what they keep.npx shadcn@latest add ui.minidev.pro/r/cancel-flow.jsonnpx shadcn@latest add https://ui.minidev.pro/r/<name>.json
Components used in this guide
Frequently asked questions
Should I use the Stripe Customer Portal or build my own billing page?
Start with the portal if you need billing working this week. Build your own page when billing should match your product or show usage from your database, and keep handing sensitive steps such as card updates to a portal session with flow_data.
Where should a Next.js app call the Stripe API?
Only on the server: in server components, server actions and route handlers, through a module marked server-only that holds the secret key. Client components receive plain props and call server actions.
Do I still need webhooks if I update the subscription in a server action?
Yes. Renewals, failed payments, portal changes and Checkout completions happen outside your action. Webhooks are the only reliable way to hear about them, and a re-sync on each event keeps your database correct.
How do I show the next billing date with the latest Stripe API?
Read current_period_end from the subscription item (subscription.items.data[0].current_period_end) on recent API versions, store it as a date when you sync, and render it from your database.