How to Add Subscription Billing with Lemon Squeezy
How to Add Subscription Billing with Lemon Squeezy
Lemon Squeezy is a merchant of record — they handle taxes, compliance, and payment processing globally. You don't need a Stripe Tax setup, no VAT registration, no sales tax headaches. Just create a product, embed checkout, and get paid.
What You'll Build
- Subscription checkout (monthly/yearly)
- Webhook handling for billing events
- Customer portal (manage subscription)
- License key validation
- Usage-based billing
Prerequisites: Next.js 14+, Lemon Squeezy account (free to create, 5% + $0.50 per transaction).
1. Setup
Install
npm install @lemonsqueezy/lemonsqueezy.js
Initialize
// lib/lemonsqueezy.ts
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js';
lemonSqueezySetup({
apiKey: process.env.LEMONSQUEEZY_API_KEY!,
});
Environment Variables
LEMONSQUEEZY_API_KEY=your_api_key
LEMONSQUEEZY_STORE_ID=your_store_id
LEMONSQUEEZY_WEBHOOK_SECRET=your_webhook_secret
NEXT_PUBLIC_LEMON_SQUEEZY_STORE_URL=https://yourstore.lemonsqueezy.com
2. Create Products
In Lemon Squeezy Dashboard:
- Products → New Product
- Name: "Pro Plan"
- Add variants:
- Monthly: $29/month
- Yearly: $290/year (save $58)
- Each variant gets a unique Variant ID
3. Checkout
Create Checkout Session
// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
export async function POST(req: Request) {
const { variantId, userId, email } = await req.json();
const checkout = await createCheckout(
process.env.LEMONSQUEEZY_STORE_ID!,
variantId,
{
checkoutData: {
email,
custom: {
user_id: userId, // Link to your user
},
},
productOptions: {
redirectUrl: `${process.env.NEXT_PUBLIC_URL}/dashboard?checkout=success`,
},
}
);
return NextResponse.json({
checkoutUrl: checkout.data?.data.attributes.url,
});
}
Checkout Button
'use client';
export function SubscribeButton({ variantId, label }: {
variantId: string;
label: string;
}) {
const handleCheckout = async () => {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variantId,
userId: 'current-user-id',
email: 'user@example.com',
}),
});
const { checkoutUrl } = await res.json();
window.location.href = checkoutUrl;
};
return <button onClick={handleCheckout}>{label}</button>;
}
Overlay Checkout (No Redirect)
'use client';
import { useEffect } from 'react';
declare global {
interface Window { createLemonSqueezy: () => void; LemonSqueezy: any; }
}
export function LemonSqueezyOverlay({ variantId }: { variantId: string }) {
useEffect(() => {
// Load Lemon Squeezy script
const script = document.createElement('script');
script.src = 'https://app.lemonsqueezy.com/js/lemon.js';
script.onload = () => window.createLemonSqueezy();
document.head.appendChild(script);
}, []);
return (
<a
href={`https://yourstore.lemonsqueezy.com/checkout/buy/${variantId}?embed=1`}
className="lemonsqueezy-button"
>
Subscribe to Pro
</a>
);
}
4. Webhooks
Set Up Webhook
In Lemon Squeezy Dashboard → Settings → Webhooks:
- URL:
https://your-app.com/api/webhooks/lemonsqueezy - Secret: Generate and save to env
- Events: All subscription events
Handle Events
// app/api/webhooks/lemonsqueezy/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('x-signature');
// Verify webhook signature
const hmac = crypto.createHmac('sha256', process.env.LEMONSQUEEZY_WEBHOOK_SECRET!);
const digest = hmac.update(body).digest('hex');
if (digest !== signature) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(body);
const eventName = event.meta.event_name;
const data = event.data.attributes;
const userId = event.meta.custom_data?.user_id;
switch (eventName) {
case 'subscription_created':
await activateSubscription(userId, {
subscriptionId: event.data.id,
plan: data.variant_name,
status: data.status,
currentPeriodEnd: data.renews_at,
});
break;
case 'subscription_updated':
await updateSubscription(userId, {
status: data.status,
plan: data.variant_name,
currentPeriodEnd: data.renews_at,
});
break;
case 'subscription_cancelled':
// Still active until period ends
await markCancelled(userId, {
endsAt: data.ends_at,
});
break;
case 'subscription_expired':
await deactivateSubscription(userId);
break;
case 'subscription_payment_success':
await recordPayment(userId, {
amount: data.total,
currency: data.currency,
});
break;
case 'subscription_payment_failed':
await handlePaymentFailure(userId);
break;
}
return NextResponse.json({ received: true });
}
5. Customer Portal
Let customers manage their own subscription:
// app/api/portal/route.ts
import { NextResponse } from 'next/server';
import { getSubscription } from '@lemonsqueezy/lemonsqueezy.js';
export async function POST(req: Request) {
const { subscriptionId } = await req.json();
const sub = await getSubscription(subscriptionId);
const portalUrl = sub.data?.data.attributes.urls.customer_portal;
return NextResponse.json({ portalUrl });
}
The portal lets customers:
- Update payment method
- Switch plans (upgrade/downgrade)
- Cancel subscription
- View invoice history
6. License Keys
For software distribution or API access:
// Validate a license key
import { validateLicense } from '@lemonsqueezy/lemonsqueezy.js';
export async function checkLicense(licenseKey: string) {
const result = await validateLicense(licenseKey);
return {
valid: result.data?.valid ?? false,
status: result.data?.license_key.status, // 'active', 'inactive', 'expired', 'disabled'
customerEmail: result.data?.meta.customer_email,
productName: result.data?.meta.product_name,
variant: result.data?.meta.variant_name,
};
}
Pricing (Lemon Squeezy Fees)
| Item | Fee |
|---|---|
| Transaction fee | 5% + $0.50 |
| Payout fee | $0 (free) |
| Monthly fee | $0 |
| Tax handling | Included (they handle VAT/sales tax) |
| Chargebacks | $15 per chargeback |
Compare: Stripe charges 2.9% + $0.30 but you handle taxes yourself. After adding Stripe Tax ($0.50/transaction) and tax compliance costs, Lemon Squeezy's all-in-one pricing is competitive.
Lemon Squeezy vs Stripe
| Feature | Lemon Squeezy | Stripe |
|---|---|---|
| Merchant of Record | ✅ (handles taxes) | ❌ (you're the MoR) |
| Global tax compliance | ✅ Included | ❌ Extra setup + Stripe Tax |
| Transaction fee | 5% + $0.50 | 2.9% + $0.30 + tax costs |
| Checkout UI | ✅ Hosted (customizable) | ✅ Hosted or embedded |
| Customer portal | ✅ Built-in | ✅ Built-in |
| License keys | ✅ Built-in | ❌ Third-party needed |
| Developer experience | Good | Excellent |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Not verifying webhook signatures | Security vulnerability | Always verify HMAC signature |
Deactivating on subscription_cancelled | User loses access before period ends | Deactivate on subscription_expired instead |
Not storing custom_data | Can't link subscription to user | Pass user_id in checkout custom data |
Ignoring subscription_payment_failed | Users stay active despite failed payments | Handle grace period and notify user |
| Not testing with test mode | Accidental real charges | Use test mode until ready for production |
Choosing a billing provider? Compare Lemon Squeezy vs Stripe vs Paddle on APIScout — merchant of record vs direct payments.