Logo FormaUI

FormaUI

All providers

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.ts is internal. It's the only file that imports @lemonsqueezy/lemonsqueezy.js and 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 outside actions.ts and webhooks/handler.ts should import it.
  • actions.ts is 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 with schemas.ts before calling service.ts, and adds the plan-based authorization helpers.
  • client.ts holds 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.ts wraps client.ts in a single React hook, usePaymentsRedirect(), adding an isRedirecting state 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 authorizationhasSubscription, requireSubscription, requirePlan, and checkPlanLimit, 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.