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
Stripe: Full-Featured Payments
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
| Factor | Stripe | LemonSqueezy |
|---|---|---|
| Tax compliance | You handle | They handle |
| Transaction fee | 2.9% + 30¢ | 5% + 50¢ |
| Customization | Full control | Limited |
| International sales | Complex tax setup | Simple |
| Developer experience | Excellent | Good |
| Best for | Growth-stage SaaS | Indie devs, small tools |
| Merchant of Record | ❌ You | ✅ LemonSqueezy |
Compare payment library health on PkgPulse.
See the live comparison
View stripe vs. lemonsqueezy on PkgPulse →