Billing Page in React: Plan Selector, Invoice Table, Card Update
Build a production-ready billing page in React with a plan selector, invoice history table, and card update flow. Stripe-connected, fully typed.
Why Billing Pages Are Harder Than They Look
You'd think a billing page is just a table plus a button. It's not. You've got plan state, Stripe's async webhooks, card tokenisation, invoice pagination, proration logic, and a user who rage-clicks "Upgrade" three times because the spinner disappeared. Every one of those is a trap.
Honestly, most billing UIs break not at the Stripe integration layer but at the React state layer. You fetch the subscription, optimistically update the UI, then Stripe fires a webhook two seconds later with a different status. Now your UI is lying. That's the problem worth solving.
This article walks you through a complete billing page: a plan selector that handles proration, an invoice history table with download links, and a card update form using Stripe Elements. All of it typed, all of it composable. We'll use React 18, @stripe/stripe-js 3.x, and TanStack Query v5 for server state.
Worth noting: we won't cover the backend Stripe webhook handler in depth here — but the frontend assumes a REST API that wraps the Stripe Node SDK. You can read about table component patterns separately if you want to pull the invoice table out as a standalone piece.
Page Layout and State Architecture
Split the page into three independent sections: <PlanSelector />, <InvoiceTable />, and <CardUpdateForm />. Each fetches its own data. Don't build one giant useBilling() hook that blocks all three sections on a single network waterfall — users on slow connections will sit staring at a skeleton for 800ms when the card form was ready in 120ms.
// BillingPage.tsx
import { Suspense } from 'react';
import { PlanSelector } from './PlanSelector';
import { InvoiceTable } from './InvoiceTable';
import { CardUpdateForm } from './CardUpdateForm';
export default function BillingPage() {
return (
<div className="max-w-3xl mx-auto py-12 space-y-10">
<Suspense fallback={<PlanSkeleton />}>
<PlanSelector />
</Suspense>
<Suspense fallback={<InvoiceSkeleton />}>
<InvoiceTable />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<CardUpdateForm />
</Suspense>
</div>
);
}Each section is wrapped in Suspense independently. This way <InvoiceTable /> renders as soon as its fetch resolves, regardless of what the plan selector is doing. That's how you get a billing page that feels fast even when Stripe's API is having one of its moments.
Quick aside: if you're on Next.js 14+, you can move each section into its own async Server Component and skip the client-side fetching entirely for the initial render. The card update form still needs to be a Client Component because Stripe Elements are browser-only.
For shared billing state — like "did the user just upgrade?" — use a lightweight Zustand store or React context scoped to the billing route. Don't pollute global app state with subscription data that 90% of your app never touches.
Plan Selector with Proration Preview
The plan selector needs to do three things: show current plan, show available plans, and — the part everyone skips — show the user what they'll actually be charged today if they upgrade mid-cycle. Stripe's createPreviewInvoice endpoint returns that proration amount. Fetch it on hover or on plan click, before the confirmation step.
// PlanSelector.tsx
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const PLANS = [
{ id: 'starter', name: 'Starter', price: 9, priceId: 'price_starter_monthly' },
{ id: 'pro', name: 'Pro', price: 29, priceId: 'price_pro_monthly' },
{ id: 'business', name: 'Business', price: 79, priceId: 'price_business_monthly' },
];
export function PlanSelector() {
const [hoveredPlan, setHoveredPlan] = useState<string | null>(null);
const qc = useQueryClient();
const { data: subscription } = useQuery({
queryKey: ['subscription'],
queryFn: () => fetch('/api/billing/subscription').then(r => r.json()),
});
const { data: preview } = useQuery({
queryKey: ['proration-preview', hoveredPlan],
queryFn: () =>
fetch(`/api/billing/preview?planId=${hoveredPlan}`).then(r => r.json()),
enabled: !!hoveredPlan && hoveredPlan !== subscription?.planId,
});
const upgrade = useMutation({
mutationFn: (priceId: string) =>
fetch('/api/billing/subscribe', {
method: 'POST',
body: JSON.stringify({ priceId }),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['subscription'] }),
});
return (
<section>
<h2 className="text-xl font-semibold mb-4">Your Plan</h2>
<div className="grid grid-cols-3 gap-4">
{PLANS.map(plan => (
<div
key={plan.id}
className={`border rounded-xl p-6 cursor-pointer transition-all ${
subscription?.planId === plan.id
? 'border-blue-500 bg-blue-50/10'
: 'border-zinc-700 hover:border-blue-400'
}`}
onMouseEnter={() => setHoveredPlan(plan.id)}
onMouseLeave={() => setHoveredPlan(null)}
>
<div className="font-semibold">{plan.name}</div>
<div className="text-2xl font-bold mt-1">${plan.price}<span className="text-sm font-normal">/mo</span></div>
{hoveredPlan === plan.id && preview && (
<div className="mt-2 text-xs text-zinc-400">
Due today: ${(preview.amountDue / 100).toFixed(2)}
</div>
)}
<button
onClick={() => upgrade.mutate(plan.priceId)}
disabled={subscription?.planId === plan.id || upgrade.isPending}
className="mt-4 w-full py-2 rounded-lg bg-blue-600 text-white text-sm disabled:opacity-40"
>
{subscription?.planId === plan.id ? 'Current plan' : 'Select'}
</button>
</div>
))}
</div>
</section>
);
}The proration preview query is enabled: false until the user hovers a different plan. That means zero extra network requests for users who don't interact with the selector. It's a small detail, but on a page that already fires three fetches on mount, you don't want a fourth unless you need it.
In practice, showing the proration amount before the click is the single highest-impact UX improvement you can make to a plan selector. Users are way less likely to open a support ticket asking "why was I charged $14.52?" if you showed them $14.52 before they clicked.
One more thing — invalidate the subscription query on success, not on onSettled. If the mutation errors, you don't want to refetch and flash the old plan state at the user while they're reading the error message.
Invoice History Table
Stripe returns invoices as a paginated list. You want to show: invoice number, billing period, amount, status badge, and a PDF download link. All of that comes back from GET /v1/invoices — you just need to proxy it through your backend so you're not exposing your Stripe secret key to the browser.
// InvoiceTable.tsx
import { useQuery } from '@tanstack/react-query';
type Invoice = {
id: string;
number: string;
created: number; // Unix timestamp
amount_paid: number;
status: 'paid' | 'open' | 'void' | 'uncollectible';
invoice_pdf: string;
period_end: number;
};
const STATUS_STYLES: Record<Invoice['status'], string> = {
paid: 'bg-green-500/10 text-green-400',
open: 'bg-yellow-500/10 text-yellow-400',
void: 'bg-zinc-500/10 text-zinc-400',
uncollectible: 'bg-red-500/10 text-red-400',
};
export function InvoiceTable() {
const { data: invoices = [] } = useQuery<Invoice[]>({
queryKey: ['invoices'],
queryFn: () => fetch('/api/billing/invoices').then(r => r.json()),
});
if (invoices.length === 0) {
return <p className="text-zinc-400 text-sm">No invoices yet.</p>;
}
return (
<section>
<h2 className="text-xl font-semibold mb-4">Invoice History</h2>
<div className="overflow-x-auto rounded-xl border border-zinc-800">
<table className="w-full text-sm">
<thead className="border-b border-zinc-800">
<tr className="text-left text-zinc-500">
<th className="px-4 py-3">Invoice</th>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Amount</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{invoices.map(inv => (
<tr key={inv.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3 font-mono text-xs">{inv.number}</td>
<td className="px-4 py-3">
{new Date(inv.period_end * 1000).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
})}
</td>
<td className="px-4 py-3">${(inv.amount_paid / 100).toFixed(2)}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_STYLES[inv.status]}`}>
{inv.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<a
href={inv.invoice_pdf}
target="_blank"
rel="noreferrer"
className="text-blue-400 hover:underline text-xs"
>
PDF
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}The status badge uses a Record<Status, className> map — that's way cleaner than a switch statement inside JSX. You can extend the same pattern to handle draft and deleted statuses when Stripe adds them.
That said, if you're building this for a product with thousands of customers, you'll want pagination on this table. Stripe caps the default list at 10 items. Add a starting_after cursor param and a "Load more" button. If you want filters (by year, by status), check out the patterns in data table filters in React — the column filter logic transfers directly.
For the date formatting, always format period_end rather than created. An invoice created on August 1st but covering July's usage should display as July's invoice. Your accounting team will thank you.
Card Update Form with Stripe Elements
Card updates don't use a regular form submission. You collect the card with Stripe Elements in the browser, call stripe.createPaymentMethod() to get a token, then send that token to your API which calls stripe.customers.update() with the new default payment method. Never send raw card numbers to your server. Ever.
// CardUpdateForm.tsx
'use client';
import { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import {
Elements,
CardElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
function CardForm() {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
setSaving(true);
setError(null);
const card = elements.getElement(CardElement);
if (!card) return;
const { paymentMethod, error: stripeError } =
await stripe.createPaymentMethod({ type: 'card', card });
if (stripeError) {
setError(stripeError.message ?? 'Card error');
setSaving(false);
return;
}
const res = await fetch('/api/billing/payment-method', {
method: 'POST',
body: JSON.stringify({ paymentMethodId: paymentMethod!.id }),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
setError('Failed to update card. Please try again.');
} else {
setSuccess(true);
}
setSaving(false);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="border border-zinc-700 rounded-lg p-4 bg-zinc-900">
<CardElement
options={{
style: {
base: {
color: '#f4f4f5',
fontSize: '16px',
fontFamily: 'Inter, sans-serif',
'::placeholder': { color: '#71717a' },
},
invalid: { color: '#f87171' },
},
}}
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{success && <p className="text-green-400 text-sm">Card updated!</p>}
<button
type="submit"
disabled={saving || !stripe}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm disabled:opacity-50"
>
{saving ? 'Saving…' : 'Update card'}
</button>
</form>
);
}
export function CardUpdateForm() {
return (
<section>
<h2 className="text-xl font-semibold mb-4">Payment Method</h2>
<Elements stripe={stripePromise}>
<CardForm />
</Elements>
</section>
);
}The CardElement styling uses Stripe's nested style object — not Tailwind classes. You're styling inside an iframe, so Tailwind can't reach it. Match your app's font and color tokens manually. A 16px base font size is the minimum you should set; anything smaller and iOS Safari auto-zooms the input on focus, which breaks the layout.
Look, the 'use client' directive at the top matters a lot here. loadStripe() calls window.Stripe under the hood, and that doesn't exist on the server. If you forget the directive in a Next.js App Router project, you'll get a cryptic hydration error at runtime rather than a build-time warning.
Worth noting: Stripe recommends using PaymentElement instead of CardElement in 2026 for new integrations — it handles wallets, SEPA, iDEAL, and other payment methods automatically. CardElement is still fine if you're card-only and want tighter control over the UI.
Styling the Billing Page to Match Your Design System
Billing pages are trust-critical UI. Users are looking at their money here. That means you probably don't want wild gradients, heavy animations, or anything that looks like it came from a marketing landing page. Clean, quiet, and confident is the right call.
That said, if your product uses a design style like glassmorphism or neumorphism throughout, carry it into the billing page for consistency. A frosted glass card for the plan selector with a 1px border at around 20% white opacity looks sharp without feeling gimmicky. Don't switch visual languages just because it's a settings page.
For the plan cards specifically, a subtle ring-2 ring-blue-500 on the active plan, combined with a 4px border-radius difference between selected and unselected states, gives enough visual hierarchy without needing icons or checkmarks. Tailwind's ring-offset-* utilities let you do this with 36px gap between the ring and the card border — ring-offset-zinc-950 for dark backgrounds.
The invoice table should use font-mono on the invoice number column and tabular-nums on the amount column. Amounts should right-align. These are small things that separate a billing page that looks like a product from one that looks like a prototype.
One more thing — add a loading state to the upgrade button that lasts until you get a webhook confirmation, not just until the API call returns. A Stripe subscription change can take 200-400ms to propagate through webhooks. If you flip the UI the moment the POST returns 200, you risk showing "Pro" in the nav before Stripe has actually created the subscription. Optimistic UI is fine here only if you're prepared to roll it back on webhook mismatch.
Error States, Edge Cases, and What Most Tutorials Skip
What happens when a user has no payment method on file and tries to upgrade? What if their card is expired? What if they're on an annual plan and you're showing monthly prices? These aren't edge cases — they're the situations your users will actually hit on day one.
For missing payment methods, show a prompt to add a card before the plan selector lets them click anything. You can get this from the Stripe customer object — if customer.invoice_settings.default_payment_method is null, render the card form first and disable the plan grid. No drama, just a clear path forward.
For failed charges, Stripe sends a invoice.payment_failed webhook. Store that state in your database and surface a banner at the top of the billing page: a border-red-500 alert with the failure reason and a direct link to the card update form. Sending users to their email to find an invoice link is friction you don't need to create.
Finally — test with Stripe's test card numbers (4242 4242 4242 4242 for success, 4000 0000 0000 9995 for insufficient funds). Run through every plan change combination: downgrade, upgrade, upgrade then immediately cancel, annual to monthly. The proration math gets weird in the last case and Stripe's behavior changed in API version 2023-10-16, so make sure your backend pins the API version explicitly.
FAQ
Yes. You can't safely make Stripe API calls from the browser without exposing your secret key. At minimum you need a few API routes that proxy to Stripe — subscription fetch, plan change, invoice list, and payment method update.
Fetch the customer's default payment method via your backend (/api/billing/payment-method) which calls stripe.paymentMethods.retrieve(). The response includes card.last4 and card.brand — display those next to the update form.
CardElement is card-only, gives you tighter UI control. PaymentElement is Stripe's newer component that handles 20+ payment methods automatically. Use PaymentElement for new builds in 2026 unless you have a specific reason to lock to cards only.
Stripe can either prorate immediately or schedule the downgrade at period end. Most SaaS products schedule at period end to avoid issuing refunds. Set proration_behavior: 'none' and billing_cycle_anchor: 'unchanged' when calling stripe.subscriptions.update() from your backend.