Hien Phan
Hien Phan
Strategy
BlogProjectsAbout
StrategyBlogProjectsAbout

Apps

๐ŸŽฏEnglish๐Ÿ“นYouTube๐Ÿš€IndieโšกLearnโš™๏ธDashboard
Back to Writing

Complete Guide to Payment Integration with Stripe, Paddle, and LemonSqueezy

Learn how to integrate multiple payment providers into your Next.js application with a unified interface for maximum flexibility.

January 24, 2026โ€ข8 min readโ€ขBoilerplate Team
Integration
Payments
Stripe
Paddle
LemonSqueezy
Next.js
Complete Guide to Payment Integration with Stripe, Paddle, and LemonSqueezy

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.

Unified Payment Architecture

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.

Why Support Multiple Providers?

  • Negotiating Power: Switch providers if rates aren't competitive
  • Regional Support: Different providers work better in different countries
  • Feature Set: Leverage unique features from each provider
  • Redundancy: Have backup options if one provider has issues
Strategy Tip

Start with one provider (Stripe is easiest), then add others as you scale. The unified interface makes this seamless.

Architecture Overview

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 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Setting Up Environment Variables

Creating the Unified Interface

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;
}

Implementing Stripe Provider

// 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";
  }
}

Provider Factory

// 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);
}

Creating the Unified Hook

// 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,
  };
}

Pricing Table Component

// 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>
  );
}

Comparison of Payment Providers

FeatureStripePaddleLemonSqueezy
Ease of Setupโญโญโญโญโญโญโญโญโญโญโญโญ
Countries Supported46+180+70+
Tax HandlingManualBuilt-inBuilt-in
CheckoutHosted onlyEmbeddedHosted only
Fees2.9% + 30ยข5% + 50ยข5% + 50ยข
Payout Speed2 daysDailyWeekly
Best ForDevelopersInternationalDigital products
Webhook Security

Always verify webhook signatures before processing events. Each provider has a different verification method - check the documentation for your chosen provider.

Testing Payments

Conclusion

You're Ready to Accept Payments!

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:

  1. Use a unified interface - Makes switching providers trivial
  2. Start with one provider - Add more as you scale
  3. Always test webhooks - Use each provider's test mode
  4. Handle errors gracefully - Payments can fail for many reasons
  5. Keep customer IDs - Link them to user records for portal access

Previous

Getting Started with Next.js and shadcn/ui

Next

Understanding the Next.js App Router

Hien Phan
Hien Phan

Indie maker building in public. Ship fast, learn loud.

Navigation

  • Blog
  • Projects
  • About

Contact

harrisonphan5@gmail.com

Based in Vietnam ๐Ÿ‡ป๐Ÿ‡ณ

ยฉ 2026 Hien Phan. All rights reserved.

Built withNext.js+Vercel