Building a customer-facing billing dashboard that allows users to manage every aspect of their subscription lifecycle. The dashboard integrates directly with Stripe as the source of truth for billing state and exposes a clean, responsive UI for self-service account management.
File structure follows Next.js App Router conventions with co-located components and a clean separation between API routes, UI components, and shared utilities.
src/
app/
(dashboard)/
billing/
page.tsx # Main billing overview
invoices/
page.tsx # Invoice history list
[id]/page.tsx # Invoice detail + PDF download
subscription/
page.tsx # Current plan + upgrade/downgrade
payment-methods/
page.tsx # Saved cards, add new
usage/
page.tsx # Usage charts + quotas
api/
billing/
invoices/route.ts # GET invoices from Stripe
subscription/route.ts # CRUD subscription
payment/route.ts # Payment method management
webhook/route.ts # Stripe webhook handler
components/
billing/
InvoiceTable.tsx # Sortable, filterable invoice list
PlanCard.tsx # Current plan display + CTA
UsageChart.tsx # Recharts usage visualization
PaymentMethodCard.tsx # Card display with delete/default
BillingAlert.tsx # Overdue, expiring, limit warnings
lib/
stripe.ts # Stripe client singleton
billing-utils.ts # Format currency, dates, plan names
types/
billing.ts # TypeScript interfaces
customers, subscriptions, invoices, usage_records@react-pdf/renderer with branded templateReduces client bundle size significantly. Invoice data fetches happen on the server, and the component streams to the client as ready. No client-side Stripe SDK needed for read operations.
Don't rebuild the payment flow. Use Stripe's hosted checkout with a return URL for plan upgrades. This offloads PCI compliance, 3DS handling, and payment method validation entirely to Stripe.
All billing state changes flow through Stripe webhooks rather than API polling. The local database is a read cache of Stripe's state. Webhook handler processes: invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, payment_method.attached, payment_method.detached.
When a user adds a payment method via Stripe Elements, show it immediately in the UI before the webhook confirms. This provides instant feedback. Reconcile on webhook delivery and roll back if the payment method was rejected.
Pricing and plan comparison pages use Incremental Static Regeneration with a 1-hour revalidation window. Plan details change infrequently, so this avoids unnecessary Stripe API calls while keeping content fresh enough.
Prisma schema with PostgreSQL. The local database mirrors Stripe state received through webhooks and adds application-specific fields like usage tracking.
model Customer { id String @id @default(uuid()) email String @unique name String? stripeCustomerId String @unique @map("stripe_customer_id") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt subscriptions Subscription[] invoices Invoice[] usageRecords UsageRecord[] @@map("customers") } model Subscription { id String @id @default(uuid()) stripeSubscriptionId String @unique @map("stripe_subscription_id") customerId String @map("customer_id") status String // active, past_due, canceled, trialing planId String @map("plan_id") planName String @map("plan_name") currentPeriodStart DateTime @map("current_period_start") currentPeriodEnd DateTime @map("current_period_end") cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt customer Customer @relation(fields: [customerId], references: [id]) @@map("subscriptions") } model Invoice { id String @id @default(uuid()) stripeInvoiceId String @unique @map("stripe_invoice_id") customerId String @map("customer_id") amountDue Int @map("amount_due") // in cents amountPaid Int @map("amount_paid") // in cents currency String @default("usd") status String // draft, open, paid, void, uncollectible invoiceUrl String? @map("invoice_url") pdfUrl String? @map("pdf_url") periodStart DateTime @map("period_start") periodEnd DateTime @map("period_end") createdAt DateTime @default(now()) customer Customer @relation(fields: [customerId], references: [id]) @@map("invoices") } model UsageRecord { id String @id @default(uuid()) customerId String @map("customer_id") metric String // api_calls, storage_bytes, seats quantity BigInt timestamp DateTime @default(now()) customer Customer @relation(fields: [customerId], references: [id]) @@index([customerId, metric, timestamp]) @@map("usage_records") }
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/api/billing/invoices |
List invoices with pagination, date range, and status filters | Yes |
GET |
/api/billing/invoices/:id |
Get invoice detail including line items | Yes |
GET |
/api/billing/invoices/:id/pdf |
Generate and download invoice PDF | Yes |
GET |
/api/billing/subscription |
Get current subscription details and available plans | Yes |
POST |
/api/billing/subscription/change |
Upgrade/downgrade plan with proration preview | Yes |
POST |
/api/billing/subscription/cancel |
Cancel subscription at period end | Yes |
GET |
/api/billing/payment-methods |
List saved payment methods | Yes |
POST |
/api/billing/payment-methods |
Attach a new payment method (Stripe token) | Yes |
DELETE |
/api/billing/payment-methods/:id |
Detach a payment method | Yes |
POST |
/api/billing/payment-methods/:id/default |
Set payment method as default | Yes |
GET |
/api/billing/usage |
Get usage data with metric and date range params | Yes |
POST |
/api/billing/webhook |
Stripe webhook receiver (signature verified) | Stripe Sig |
stripe.webhooks.constructEvent(). Reject any request with an invalid or missing signature.upcoming_invoice preview endpoint to get exact proration amounts. Display the Stripe-calculated amount to the user before confirming the plan change.Intl.NumberFormat with the currency code from Stripe (supports all 135+ currencies). Handle zero-decimal currencies (JPY, KRW) by checking Stripe's currency metadata rather than assuming cents.