Lemon Squeezy
A complete, logic-only payments module for Lemon Squeezy. It covers hosted checkout, subscription lifecycle management, webhook verification and sync, refunds, and plan-based access control — copy-paste ready for any Next.js App Router project, with no UI included.
Dependencies
zod: schema validation for every public input (checkout, subscription changes, refunds) and for the shape of incoming webhook payloads.@lemonsqueezy/lemonsqueezy.js: Lemon Squeezy's official SDK. It's the only package that talks to the provider's API directly.
Folder structure
payments/
types.ts // Shared TypeScript types (Plan, Subscription, Customer, Order, errors, etc)
constants.ts // Technical config: API URLs, webhook event list, subscription access rules
plans.ts // Centralized plan definitions, mapped to Lemon Squeezy Product/Variant IDs
schemas.ts // Zod schemas for public inputs and webhook payloads
service.ts // INTERNAL — raw SDK calls, provider initialization, attribute mappers
actions.ts // "use server" — the public API surface (checkout, subscriptions, refunds, auth)
client.ts // Client Component helpers (redirect to checkout / customer portal)
hooks.ts // React hooks wrapping client.ts (adds loading state)
webhooks/
handler.ts // INTERNAL — signature verification, payload validation, event dispatch
route.ts // Exact content for app/api/webhooks/lemonsqueezy/route.ts
env.example // Required environment variables
How the files fit together
service.tsis internal. It's the only file that imports@lemonsqueezy/lemonsqueezy.jsand holds the API key. It exposes raw, typed functions (createCheckoutSession,getSubscription,refundOrder, etc) and the mappers that turn Lemon Squeezy's raw (snake_case) attributes into the application's types. Nothing outsideactions.tsandwebhooks/handler.tsshould import it.actions.tsis the module's public entry point on the server. It's marked"use server", so every exported function is a Server Action you can call directly from a Server Component, a<form action={...}>, or a client event handler. It validates inputs withschemas.tsbefore callingservice.ts, and adds the plan-based authorization helpers.client.tsholds small, framework-agnostic helpers for Client Components — they only ever redirect the browser to a URL that was already created on the server (checkout, customer portal, update-payment-method). They never call the provider directly.hooks.tswrapsclient.tsin a single React hook,usePaymentsRedirect(), adding anisRedirectingstate so you can disable a button while the browser navigates.
What actions.ts covers
- Checkout — create a hosted checkout session for any plan + billing interval defined in
plans.ts. - Subscriptions — fetch the current state of a subscription, change plan (upgrade/downgrade), cancel, resume (within the grace period), and get a signed Customer Portal URL.
- Customers — fetch or create a Lemon Squeezy customer ahead of the checkout flow.
- Refunds — full or partial refunds on an order.
- Plan-based authorization —
hasSubscription,requireSubscription,requirePlan, andcheckPlanLimit, to gate features and enforce per-plan usage limits.
Usage examples
Checkout
"use client";
import { usePaymentsRedirect } from "@/payments/hooks";
import * as payments from "@/payments/actions";
export function SubscribeButton() {
const { redirectToCheckout, isRedirecting } = usePaymentsRedirect();
async function handleClick() {
const result = await payments.createCheckout({
planId: "pro",
interval: "month",
userId: currentUserId,
customerEmail: currentUserEmail,
redirectUrl: `${window.location.origin}/dashboard?checkout=success`,
});
if (result.success) redirectToCheckout(result.data.url);
}
return (
<button onClick={handleClick} disabled={isRedirecting}>
Subscribe
</button>
);
}
Manage a subscription
import * as payments from "@/payments/actions";
// Current state
const subscription = await payments.getSubscription(subscriptionId, userId);
// Upgrade to yearly billing
await payments.changeSubscriptionPlan(
{ subscriptionId, planId: "pro", interval: "year" },
userId,
);
// Cancel, then resume within the grace period
await payments.cancelSubscription({ subscriptionId }, userId);
await payments.resumeSubscription({ subscriptionId }, userId);
Plan-gated access
const subscriptionResult = await payments.getSubscription(subscriptionId, userId);
const subscription = subscriptionResult.success ? subscriptionResult.data : null;
const check = await payments.requirePlan(subscription, "pro");
if (!check.success) {
// check.error.code === "FORBIDDEN"
}
const limitCheck = await payments.checkPlanLimit("pro", "projects", currentProjectCount);
if (limitCheck.success && !limitCheck.data.allowed) {
// user hit their plan's limit
}
Webhooks
payments/webhooks/route.ts ships ready to copy to app/api/webhooks/lemonsqueezy/route.ts — signature verification, payload validation, and event dispatch are already resolved. Only the business-logic TODOs need filling in:
onSubscriptionCreated: async ({ userId, data }) => {
await db.subscriptions.upsert({
userId,
subscriptionId: data.id,
planId: data.planId,
status: data.status,
});
},
What you can build with this
This module is the payments layer for any subscription-based or one-time-purchase product: a SaaS with tiered plans and usage limits, a membership site, a paid API, or a digital product store. Combined with an auth module (Clerk, Supabase, Auth0) for userId and your own database for persistence, actions.ts and webhooks/route.ts are the only two files a project needs to touch to have working, production-grade billing — everything else (signature verification, attribute mapping, plan configuration) is already resolved.