Learn how to integrate multiple payment providers into your Next.js application with a unified interface for maximum flexibility.
Accepting payments is a critical feature for any SaaS application. This guide shows you how to build a unified payment system that supports multiple providers.
We'll build a provider-agnostic interface that lets you switch between Stripe, Paddle, and LemonSqueezy with minimal code changes. This approach gives you flexibility and negotiating power.
Start with one provider (Stripe is easiest), then add others as you scale. The unified interface makes this seamless.
Our payment system uses the Strategy Pattern with a factory function:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Application Layer โ
โ (Components, Hooks, Pages) โ
โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Unified Payment Interface โ
โ (PaymentProvider interface) โ
โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโดโโโโโโโโฌโโโโโโโโโโโโโโโ
โผ โผ โผ
โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ
โ Stripe โ โ Paddle โ โ LemonS โ
โ Provider โ โ Provider โ โ Provider โ
โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ
Define a standard interface that all providers implement:
// lib/payment/types.ts
export type PaymentProviderType = "stripe" | "paddle" | "lemonsqueezy";
export type UnifiedSubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "canceled"
| "unpaid"
| "incomplete";
export interface UnifiedSubscription {
id: string;
provider: PaymentProviderType;
providerSubscriptionId: string;
status: UnifiedSubscriptionStatus;
priceId: string;
amount: number;
currency: string;
interval: "month" | "year";
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
}
export interface PaymentProvider {
createCheckoutSession(params: {
userId: string;
priceId: string;
successUrl: string;
cancelUrl: string;
}): Promise<string>;
createPortalSession(params: {
customerId: string;
returnUrl: string;
}): Promise<string>;
cancelSubscription(subscriptionId: string): Promise<void>;
resumeSubscription(subscriptionId: string): Promise<void>;
getSubscription(subscriptionId: string): Promise<UnifiedSubscription>;
verifyWebhook(rawBody: string, signature: string): boolean;
}
// lib/payment/stripe-provider.ts
import Stripe from "stripe";
import type { PaymentProvider, UnifiedSubscription } from "./types";
export class StripePaymentProvider implements PaymentProvider {
private stripe: Stripe;
constructor() {
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
}
async createCheckoutSession(params: {
userId: string;
priceId: string;
successUrl: string;
cancelUrl: string;
}): Promise<string> {
const session = await this.stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: params.priceId, quantity: 1 }],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: { userId: params.userId },
});
return session.url!;
}
async createPortalSession(params: {
customerId: string;
returnUrl: string;
}): Promise<string> {
const session = await this.stripe.billingPortal.sessions.create({
customer: params.customerId,
return_url: params.returnUrl,
});
return session.url;
}
async cancelSubscription(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
async resumeSubscription(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
}
async getSubscription(subscriptionId: string): Promise<UnifiedSubscription> {
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
return {
id: subscription.id,
provider: "stripe",
providerSubscriptionId: subscription.id,
status: this.mapStatus(subscription.status),
priceId: subscription.items.data[0].price.id,
amount: subscription.items.data[0].price.unit_amount || 0,
currency: subscription.currency,
interval: subscription.items.data[0].price.recurring?.interval || "month",
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
};
}
verifyWebhook(rawBody: string, signature: string): boolean {
try {
this.stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
return true;
} catch {
return false;
}
}
private mapStatus(status: Stripe.Subscription.Status): UnifiedSubscriptionStatus {
const statusMap: Record<string, UnifiedSubscriptionStatus> = {
active: "active",
trialing: "trialing",
past_due: "past_due",
canceled: "canceled",
unpaid: "unpaid",
incomplete: "incomplete",
};
return statusMap[status] || "incomplete";
}
}
// lib/payment/providers.ts
import { StripePaymentProvider } from "./stripe-provider";
import { PaddlePaymentProvider } from "./paddle-provider";
import { LemonSqueezyPaymentProvider } from "./lemonsqueezy-provider";
import type { PaymentProvider, PaymentProviderType } from "./types";
export function createPaymentProvider(
type: PaymentProviderType = getPaymentProviderType()
): PaymentProvider {
switch (type) {
case "stripe":
return new StripePaymentProvider();
case "paddle":
return new PaddlePaymentProvider();
case "lemonsqueezy":
return new LemonSqueezyPaymentProvider();
default:
return new StripePaymentProvider();
}
}
function getPaymentProviderType(): PaymentProviderType {
const provider = process.env.PAYMENT_PROVIDER;
if (provider === "stripe" || provider === "paddle" || provider === "lemonsqueezy") {
return provider;
}
return "stripe"; // Default to Stripe
}
export function isProviderEnabled(type: PaymentProviderType): boolean {
switch (type) {
case "stripe":
return !!process.env.STRIPE_SECRET_KEY;
case "paddle":
return !!process.env.PADDLE_API_KEY;
case "lemonsqueezy":
return !!process.env.LEMONSQUEEZY_API_KEY;
}
}
export function getAvailableProviders(): PaymentProviderType[] {
return (["stripe", "paddle", "lemonsqueezy"] as const).filter(isProviderEnabled);
}
// hooks/use-payment.ts
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "./use-auth";
import { createPaymentProvider } from "@/lib/payment/providers";
export function usePayment() {
const { user } = useAuth();
const router = useRouter();
const provider = createPaymentProvider();
const createCheckout = useCallback(
async (priceId: string) => {
if (!user?.id) throw new Error("User not authenticated");
const response = await fetch("/api/payment/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
if (!response.ok) throw new Error("Failed to create checkout");
const { url } = await response.json();
return url;
},
[user?.id]
);
const createPortal = useCallback(async () => {
const response = await fetch("/api/payment/portal", {
method: "POST",
});
if (!response.ok) throw new Error("Failed to create portal session");
const { url } = await response.json();
return url;
}, []);
const cancelSubscription = useCallback(async (subscriptionId: string) => {
const response = await fetch(`/api/payment/subscription/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscriptionId }),
});
if (!response.ok) throw new Error("Failed to cancel subscription");
}, []);
const resumeSubscription = useCallback(async (subscriptionId: string) => {
const response = await fetch(`/api/payment/subscription/resume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscriptionId }),
});
if (!response.ok) throw new Error("Failed to resume subscription");
}, []);
return {
createCheckout,
createPortal,
cancelSubscription,
resumeSubscription,
};
}
// components/pricing-table.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Check } from "lucide-react";
import { usePayment } from "@/hooks/use-payment";
const PLANS = [
{
name: "Starter",
price: 0,
interval: "month" as const,
description: "Perfect for trying out the platform",
features: [
"5 projects",
"1GB storage",
"Community support",
"Basic analytics",
],
priceIds: {
stripe: null, // Free plan
paddle: null,
lemonsqueezy: null,
},
},
{
name: "Pro",
price: 29,
interval: "month" as const,
description: "For growing teams and businesses",
features: [
"Unlimited projects",
"100GB storage",
"Priority support",
"Advanced analytics",
"Custom domains",
"API access",
],
priceIds: {
stripe: "price_...",
paddle: "pri_...",
lemonsqueezy: "...",
},
popular: true,
},
{
name: "Enterprise",
price: 99,
interval: "month" as const,
description: "For large organizations",
features: [
"Everything in Pro",
"Unlimited storage",
"Dedicated support",
"SLA guarantee",
"Advanced security",
"Custom contracts",
],
priceIds: {
stripe: "price_...",
paddle: "pri_...",
lemonsqueezy: "...",
},
},
];
export function PricingTable() {
const [billingCycle, setBillingCycle] = useState<"month" | "year">("month");
const { createCheckout } = usePayment();
const handleSubscribe = async (priceId: string) => {
const url = await createCheckout(priceId);
window.location.href = url;
};
return (
<div className="grid gap-8 md:grid-cols-3">
{PLANS.map((plan) => (
<Card
key={plan.name}
className={plan.popular ? "border-primary shadow-lg scale-105" : ""}
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
{plan.name}
{plan.popular && <Badge>Most Popular</Badge>}
</CardTitle>
<CardDescription>{plan.description}</CardDescription>
<div className="mt-4">
<span className="text-4xl font-bold">
${billingCycle === "year" ? plan.price * 10 : plan.price}
</span>
<span className="text-muted-foreground">/{billingCycle}</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
<span>{feature}</span>
</li>
))}
</ul>
<Button
className="w-full"
variant={plan.popular ? "default" : "outline"}
onClick={() => handleSubscribe(plan.priceIds.stripe!)}
disabled={!plan.priceIds.stripe}
>
{plan.price === 0 ? "Get Started" : "Subscribe"}
</Button>
</CardContent>
</Card>
))}
</div>
);
}
| Feature | Stripe | Paddle | LemonSqueezy |
|---|---|---|---|
| Ease of Setup | โญโญโญโญโญ | โญโญโญโญ | โญโญโญ |
| Countries Supported | 46+ | 180+ | 70+ |
| Tax Handling | Manual | Built-in | Built-in |
| Checkout | Hosted only | Embedded | Hosted only |
| Fees | 2.9% + 30ยข | 5% + 50ยข | 5% + 50ยข |
| Payout Speed | 2 days | Daily | Weekly |
| Best For | Developers | International | Digital products |
Always verify webhook signatures before processing events. Each provider has a different verification method - check the documentation for your chosen provider.
With this unified payment architecture, you can easily switch between providers, negotiate better rates, and provide the best experience for your customers worldwide.
The key takeaways: