Billing in Next.js Supabase Turbo

Complete guide to implementing billing in your Next.js Supabase SaaS. Configure subscriptions, one-off payments, metered usage, and per-seat pricing with Stripe, Lemon Squeezy, or Paddle.

Makerkit's billing system lets you accept payments through Stripe, Lemon Squeezy, or Paddle with a unified API. You define your pricing once in a schema, and the gateway routes requests to your chosen provider. Switching providers requires changing one environment variable.

Quick Start

Set your billing provider:

NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle

Update the database configuration to match:

UPDATE public.config SET billing_provider = 'stripe';

Then configure your billing schema with your products and pricing.

Choose Your Provider

ProviderBest ForTax HandlingMulti-line Items
StripeMaximum flexibility, global reachYou handle (or use Stripe Tax)Yes
Lemon SqueezySimplicity, automatic tax complianceMerchant of RecordNo (1 per plan)
PaddleB2B SaaS, automatic tax complianceMerchant of RecordNo (flat + per-seat only)

Merchant of Record means Lemon Squeezy and Paddle handle VAT, sales tax, and compliance globally. With Stripe, you're responsible for tax collection (though Stripe Tax can help).

Supported Pricing Models

Makerkit supports four billing models out of the box:

Flat Subscriptions

Fixed monthly or annual pricing. The most common SaaS model.

{
id: 'price_xxx',
name: 'Pro Plan',
cost: 29,
type: 'flat',
}

Learn more about configuring flat subscriptions →

Per-Seat Billing

Charge based on team size. Makerkit automatically updates seat counts when members join or leave.

{
id: 'price_xxx',
name: 'Team',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 3, cost: 0 }, // First 3 seats free
{ upTo: 10, cost: 12 }, // $12/seat up to 10
{ upTo: 'unlimited', cost: 10 },
]
}

Configure per-seat billing →

Metered Usage

Charge based on consumption (API calls, storage, tokens). Report usage through the billing API.

{
id: 'price_xxx',
name: 'API Requests',
cost: 0,
type: 'metered',
unit: 'requests',
tiers: [
{ upTo: 1000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
]
}

Set up metered billing →

One-Off Payments

Lifetime deals, add-ons, or credits. Stored in the orders table instead of subscriptions.

{
paymentType: 'one-time',
lineItems: [{
id: 'price_xxx',
name: 'Lifetime Access',
cost: 299,
type: 'flat',
}]
}

Configure one-off payments →

Credit-Based Billing

For AI SaaS and token-based systems. Combine subscriptions with a credits table for consumption tracking.

Implement credit-based billing →

Architecture Overview

The billing system uses a provider-agnostic architecture:

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Your App │────▶│ Gateway │────▶│ Provider │
│ (billing.config) │ (routes requests) │ (Stripe/LS/Paddle)
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Database │◀────│ Webhook Handler│◀────│ Webhook │
│ (subscriptions) │ (processes events) │ (payment events)
└─────────────────┘ └─────────────────┘ └─────────────────┘

Package structure:

  • @kit/billing (core): Schema validation, interfaces, types
  • @kit/billing-gateway: Provider routing, unified API
  • @kit/stripe: Stripe-specific implementation
  • @kit/lemon-squeezy: Lemon Squeezy-specific implementation
  • @kit/paddle: Paddle-specific implementation (plugin)

This abstraction means your application code stays the same regardless of provider. The billing schema defines what you sell, and each provider package handles the API specifics.

Database Schema

Billing data is stored in four main tables:

TablePurpose
billing_customersLinks accounts to provider customer IDs
subscriptionsActive and historical subscription records
subscription_itemsLine items within subscriptions (for per-seat, metered)
ordersOne-off payment records
order_itemsItems within one-off orders

All tables have Row Level Security (RLS) enabled. Users can only read their own billing data.

Configuration Files

billing.config.ts

Your pricing schema lives at apps/web/config/billing.config.ts:

import { createBillingSchema } from '@kit/billing';
export default createBillingSchema({
provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER,
products: [
{
id: 'starter',
name: 'Starter',
description: 'For individuals',
currency: 'USD',
plans: [/* ... */],
},
],
});

Full billing schema documentation →

Environment Variables

Each provider requires specific environment variables:

Stripe:

STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_...

Lemon Squeezy:

LEMON_SQUEEZY_SECRET_KEY=...
LEMON_SQUEEZY_SIGNING_SECRET=...
LEMON_SQUEEZY_STORE_ID=...

Paddle:

PADDLE_API_KEY=...
PADDLE_WEBHOOK_SECRET_KEY=...
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=...

Common Tasks

Check if an account has a subscription

import { createAccountsApi } from '@kit/accounts/api';
const api = createAccountsApi(supabaseClient);
const subscription = await api.getSubscription(accountId);
if (subscription?.status === 'active') {
// User has active subscription
}

Create a checkout session

import { createBillingGatewayService } from '@kit/billing-gateway';
const service = await createBillingGatewayService(provider);
const { checkoutToken } = await service.createCheckoutSession({
accountId,
plan,
returnUrl: `${origin}/billing/return`,
customerEmail: user.email,
});

Handle billing webhooks

Webhooks are processed at /api/billing/webhook. Extend the handler for custom logic:

await service.handleWebhookEvent(request, {
onCheckoutSessionCompleted: async (subscription) => {
// Send welcome email, provision resources, etc.
},
onSubscriptionDeleted: async (subscriptionId) => {
// Clean up, send cancellation email, etc.
},
});

Full webhook documentation →

Next Steps

  1. Configure your billing schema to define your products and pricing
  2. Set up your payment provider: Stripe, Lemon Squeezy, or Paddle
  3. Handle webhooks for payment events
  4. Use the billing API to manage subscriptions programmatically

For advanced use cases: