Skip to main content

Stripe Integration for SaaS Billing

Accepting payments requires handling sensitive card data, PCI compliance, and recurring subscriptions. Stripe abstracts all of this: you never touch card details, and Stripe handles compliance. Integrating Stripe into a React SaaS dashboard means creating a checkout flow, displaying subscription status, managing plan upgrades, and showing invoices—all without storing payment methods locally.

I've integrated Stripe in five SaaS products, and the most reliable approach uses Stripe's prebuilt components (@stripe/react-stripe-js) and webhooks on the backend. This article teaches you that approach.

What You'll Learn

You'll build:

  • A Stripe Checkout integration for one-time payments
  • A Stripe Billing Portal for subscription management
  • Plan upgrade/downgrade UI
  • Invoice history and download
  • Webhook handling for subscription events
  • Secure communication between React and Stripe without exposing API keys

Prerequisites

You need the tenant and settings setup from the previous article. You'll need a Stripe account (free tier available at https://stripe.com). Install Stripe libraries:

npm install @stripe/react-stripe-js @stripe/js

Step 1: Initialize Stripe in Your App

Create src/lib/stripe.ts:

import { loadStripe } from '@stripe/js';

const stripePromise = loadStripe(
import.meta.env.VITE_STRIPE_PUBLIC_KEY || ''
);

export { stripePromise };

In src/App.tsx, wrap your component tree with Stripe's provider:

import { Elements } from '@stripe/react-stripe-js';
import { stripePromise } from './lib/stripe';

function App() {
return (
<Elements stripe={stripePromise}>
{/* Your app components here */}
</Elements>
);
}

Add your Stripe public key to .env.local:

VITE_STRIPE_PUBLIC_KEY=pk_test_your_key_here

Step 2: Fetch the Customer's Subscription Status

Create src/hooks/useSubscription.ts:

import { useQuery } from '@tanstack/react-query';
import { useTenant } from '../context/TenantContext';

export interface SubscriptionStatus {
customerId: string;
subscriptionId: string | null;
planId: string;
status: 'active' | 'past_due' | 'canceled' | 'trial';
currentPeriodEnd: string;
cancelAtPeriodEnd: boolean;
}

export function useSubscription() {
const { currentTenant } = useTenant();

return useQuery<SubscriptionStatus>({
queryKey: ['subscription', currentTenant?.id],
queryFn: async () => {
if (!currentTenant) throw new Error('No tenant selected');

const response = await fetch(`/api/tenants/${currentTenant.id}/subscription`);
if (!response.ok) throw new Error('Failed to fetch subscription');
return response.json();
},
enabled: !!currentTenant,
});
}

The backend queries Stripe's API to get the tenant's subscription status.

Step 3: Create a Billing Page with Plan Selection

Create src/pages/BillingPage.tsx:

import { useState } from 'react';
import { useTenant } from '../context/TenantContext';
import { useSubscription } from '../hooks/useSubscription';

const PLANS = [
{ id: 'free', name: 'Free', price: '$0', features: ['5 projects', '1 GB storage'] },
{ id: 'pro', name: 'Pro', price: '$29/mo', features: ['Unlimited projects', '100 GB storage', 'Priority support'] },
{ id: 'enterprise', name: 'Enterprise', price: 'Custom', features: ['Everything in Pro', 'SSO', 'Dedicated support'] },
];

export default function BillingPage() {
const { currentTenant } = useTenant();
const { data: subscription, isLoading } = useSubscription();
const [isCheckingOut, setIsCheckingOut] = useState(false);

const handleUpgrade = async (planId: string) => {
setIsCheckingOut(true);

try {
const response = await fetch(`/api/tenants/${currentTenant?.id}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planId }),
});

if (response.ok) {
const { sessionId } = await response.json();
// Redirect to Stripe Checkout (handled by backend)
window.location.href = `https://checkout.stripe.com/c/pay/${sessionId}`;
}
} catch (error) {
console.error('Checkout failed:', error);
alert('Failed to initiate checkout');
} finally {
setIsCheckingOut(false);
}
};

if (isLoading) return <p>Loading billing info...</p>;

return (
<div className="max-w-6xl mx-auto p-6 space-y-8">
<h1 className="text-3xl font-bold">Billing & Plans</h1>

{/* Current Subscription */}
{subscription && (
<div className="bg-blue-50 rounded-lg p-6 border border-blue-200">
<h2 className="text-lg font-semibold">Current Plan</h2>
<p className="text-gray-700 mt-2">
You are on the <strong>{subscription.planId}</strong> plan.
{subscription.status === 'active' && (
<span className="ml-2 text-green-600">Active until {new Date(subscription.currentPeriodEnd).toLocaleDateString()}</span>
)}
</p>
</div>
)}

{/* Plan Selection */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{PLANS.map((plan) => {
const isCurrentPlan = subscription?.planId === plan.id;
return (
<div
key={plan.id}
className={`rounded-lg p-6 border-2 ${
isCurrentPlan ? 'border-blue-600 bg-blue-50' : 'border-gray-200 bg-white'
}`}
>
<h3 className="text-xl font-semibold">{plan.name}</h3>
<p className="text-2xl font-bold mt-2">{plan.price}</p>
<ul className="mt-4 space-y-2">
{plan.features.map((feature, i) => (
<li key={i} className="text-sm text-gray-600 flex items-start">
<span className="text-green-600 mr-2"></span> {feature}
</li>
))}
</ul>
<button
onClick={() => handleUpgrade(plan.id)}
disabled={isCurrentPlan || isCheckingOut}
className={`w-full mt-6 py-2 rounded-lg font-medium ${
isCurrentPlan
? 'bg-gray-300 text-gray-600 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isCurrentPlan ? 'Current Plan' : isCheckingOut ? 'Loading...' : 'Select'}
</button>
</div>
);
})}
</div>
</div>
);
}

Clicking "Select" initiates a checkout session on the backend, which returns a Stripe Checkout session ID. Redirect to Stripe-hosted checkout (no PCI burden).

Step 4: Open the Billing Portal for Self-Service

Stripe's Billing Portal lets customers manage invoices, update payment methods, and cancel subscriptions—all without custom UI. Create a button:

import { useTenant } from '../context/TenantContext';

export function BillingPortalButton() {
const { currentTenant } = useTenant();
const [isLoading, setIsLoading] = useState(false);

const handleOpen = async () => {
setIsLoading(true);

try {
const response = await fetch(`/api/tenants/${currentTenant?.id}/billing-portal`, {
method: 'POST',
});

if (response.ok) {
const { portalUrl } = await response.json();
window.location.href = portalUrl;
}
} catch (error) {
alert('Failed to open billing portal');
} finally {
setIsLoading(false);
}
};

return (
<button
onClick={handleOpen}
disabled={isLoading}
className="px-4 py-2 bg-gray-600 text-white rounded-lg disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Manage Billing'}
</button>
);
}

The backend creates a Billing Portal session and returns the URL. Stripe handles the rest: invoices, payment methods, subscriptions.

Step 5: Display Invoice History

Create src/hooks/useInvoices.ts:

import { useQuery } from '@tanstack/react-query';
import { useTenant } from '../context/TenantContext';

export interface Invoice {
id: string;
number: string;
amountPaid: number;
amountDue: number;
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void';
issuedAt: string;
dueDateAt: string;
pdfUrl: string;
}

export function useInvoices() {
const { currentTenant } = useTenant();

return useQuery<Invoice[]>({
queryKey: ['invoices', currentTenant?.id],
queryFn: async () => {
if (!currentTenant) return [];

const response = await fetch(`/api/tenants/${currentTenant.id}/invoices`);
if (!response.ok) throw new Error('Failed to fetch invoices');
return response.json();
},
enabled: !!currentTenant,
});
}

Then display them:

import { useInvoices } from '../hooks/useInvoices';

export function InvoiceHistory() {
const { data: invoices, isLoading } = useInvoices();

if (isLoading) return <p>Loading invoices...</p>;

return (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Invoice History</h2>
{invoices && invoices.length > 0 ? (
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2">Invoice</th>
<th className="text-left py-2">Amount</th>
<th className="text-left py-2">Status</th>
<th className="text-left py-2">Date</th>
<th className="text-left py-2">Action</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id} className="border-b hover:bg-gray-50">
<td className="py-3">{invoice.number}</td>
<td className="py-3">${(invoice.amountPaid / 100).toFixed(2)}</td>
<td className="py-3">
<span className={`px-2 py-1 rounded text-sm font-medium ${
invoice.status === 'paid' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{invoice.status}
</span>
</td>
<td className="py-3">{new Date(invoice.issuedAt).toLocaleDateString()}</td>
<td className="py-3">
<a
href={invoice.pdfUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Download PDF
</a>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-gray-500">No invoices yet</p>
)}
</div>
);
}

Stripe Webhook Flow

Your backend must listen for Stripe webhooks (invoice.paid, customer.subscription.updated, etc.) and update your database:

EventAction
customer.subscription.createdRecord subscription in DB
customer.subscription.updatedUpdate plan, period_end
customer.subscription.deletedMark as canceled
invoice.payment_succeededMark invoice paid
invoice.payment_failedAlert customer

Never rely solely on the frontend for billing state—webhooks are the source of truth.

Key Takeaways

  • Use Stripe Checkout (hosted) or the Billing Portal to avoid PCI compliance burden. Never build custom payment forms unless you're PCI-DSS Level 1 certified.
  • Always verify subscription status on the backend before granting premium features. A malicious user could modify the frontend to claim they're on a premium plan.
  • Store Stripe customer IDs and subscription IDs in your database, keyed by tenant, to avoid re-querying Stripe repeatedly.
  • Webhook events are the source of truth for billing state—always process them and update your database.
  • Use invoice PDFs from Stripe (pdfUrl) rather than generating your own to ensure compliance and consistency.

Frequently Asked Questions

What's the difference between Checkout and the Billing Portal?

Checkout handles one-time purchases or initial subscription setup. The Billing Portal handles recurring subscriptions: payment method updates, invoices, cancellations, and plan changes. Use both together.

Do I need to handle PCI compliance?

No, if you use Stripe Checkout or the Billing Portal (Stripe-hosted). You only need to be PCI-compliant if you handle raw card data. Stripe handles compliance for you.

How do I test Stripe integration locally?

Use Stripe's test API keys (pk_test_...) and card numbers like 4242 4242 4242 4242. See https://stripe.com/docs/testing for all test scenarios.

What if a payment fails?

Stripe retries automatically for 3 days. You can also listen to invoice.payment_failed webhooks and send a customer email prompting them to update their payment method via the Billing Portal.

Can I offer annual billing?

Yes, create two price objects in Stripe: one monthly, one annual. The checkout flow lets customers choose. The annual discount is often 20%, e.g., $348/year vs $360/year monthly.

Further Reading