Skip to main content

How to Add Payments to Your App: Stripe vs LemonSqueezy

·PkgPulse Team

TL;DR

Stripe for full control and complex billing; LemonSqueezy as Merchant of Record for indie developers. Stripe (~3M weekly downloads) handles any payment flow but you're responsible for tax compliance, fraud, and chargebacks. LemonSqueezy acts as the Merchant of Record — they handle VAT, sales tax, and compliance globally. Solo developers and small teams often choose Lemon because tax is genuinely hard; larger products usually choose Stripe for control.

Key Takeaways

  • Stripe: ~3M npm downloads — full control, complex billing, best documentation
  • LemonSqueezy: ~20K npm downloads — Merchant of Record, handles tax compliance
  • Merchant of Record: LemonSqueezy/Paddle pay you as a vendor and handle all tax
  • Stripe: 2.9% + 30¢ per transaction — LemonSqueezy: 5% + 50¢ (tax handling included)
  • Webhooks: required for both — your server needs to receive payment events

npm install stripe  # Server SDK
npm install @stripe/stripe-js @stripe/react-stripe-js  # Client SDK

One-Time Payment (Checkout)

// Server: create Stripe checkout session
// app/api/checkout/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

export async function POST(request: Request) {
  const { priceId, userId } = await request.json();

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',                // 'subscription' for recurring
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],
    customer_email: user.email,
    metadata: { userId },           // Pass through to webhook
    success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
  });

  return Response.json({ url: session.url });
}
// Client: redirect to Stripe checkout
function BuyButton({ priceId }: { priceId: string }) {
  const handleCheckout = async () => {
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId }),
    });
    const { url } = await res.json();
    window.location.href = url;  // Redirect to Stripe-hosted checkout
  };

  return <button onClick={handleCheckout}>Buy Now</button>;
}

Subscriptions

// Create subscription checkout
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  payment_method_types: ['card'],
  line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
  metadata: { userId },
  subscription_data: {
    trial_period_days: 14,  // Free trial
    metadata: { userId },
  },
  success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
  cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
});

Webhooks (Required)

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.CheckoutSession;
      const userId = session.metadata?.userId;
      // Grant access, send welcome email, etc.
      await db.user.update({
        where: { id: userId },
        data: { plan: 'pro', stripeCustomerId: session.customer as string },
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      const userId = subscription.metadata.userId;
      await db.user.update({
        where: { id: userId },
        data: { plan: 'free' },
      });
      break;
    }

    case 'invoice.payment_failed': {
      // Send dunning email
      break;
    }
  }

  return new Response('ok');
}

LemonSqueezy: Merchant of Record

npm install @lemonsqueezy/lemonsqueezy.js
// lib/lemonsqueezy.ts
import { lemonSqueezySetup, createCheckout } from '@lemonsqueezy/lemonsqueezy.js';

lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY! });

// Create checkout URL
export async function createPaymentUrl({
  variantId,
  userId,
  userEmail,
}: {
  variantId: string;
  userId: string;
  userEmail: string;
}) {
  const { data, error } = await createCheckout(
    process.env.LEMONSQUEEZY_STORE_ID!,
    variantId,
    {
      checkoutData: {
        email: userEmail,
        custom: { user_id: userId },  // Passed through to webhooks
      },
      checkoutOptions: {
        dark: true,
      },
      productOptions: {
        redirectUrl: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
      },
    }
  );

  if (error) throw new Error(error.message);
  return data?.data.attributes.url;
}
// LemonSqueezy webhook handler
import crypto from 'crypto';

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('x-signature')!;

  // Verify signature
  const hash = crypto
    .createHmac('sha256', process.env.LEMONSQUEEZY_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex');

  if (hash !== signature) {
    return new Response('Invalid signature', { status: 400 });
  }

  const event = JSON.parse(body);

  switch (event.meta.event_name) {
    case 'order_created': {
      const userId = event.meta.custom_data?.user_id;
      await db.user.update({
        where: { id: userId },
        data: { plan: 'pro' },
      });
      break;
    }

    case 'subscription_cancelled': {
      const userId = event.meta.custom_data?.user_id;
      await db.user.update({
        where: { id: userId },
        data: { plan: 'free' },
      });
      break;
    }
  }

  return new Response('ok');
}

Decision Guide

FactorStripeLemonSqueezy
Tax complianceYou handleThey handle
Transaction fee2.9% + 30¢5% + 50¢
CustomizationFull controlLimited
International salesComplex tax setupSimple
Developer experienceExcellentGood
Best forGrowth-stage SaaSIndie devs, small tools
Merchant of Record❌ You✅ LemonSqueezy

Compare payment library health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.