Chapter 22: Charge Money 💰

Chapter 22: Charge Money 💰

"Implementing payments is where your SaaS transforms from a project into a business—the moment you can charge customers is when your MVP becomes truly valuable."

Building a great product is only half the battle—you need a way to monetize it. In this chapter, we'll explore how to implement payment processing in your SaaS application using popular payment providers like Stripe and Lemon Squeezy. We'll cover everything from setting up checkout flows to handling subscriptions and managing customer billing through webhooks and customer portals.

Stripe / Lemon Squeezy 🍋

Before writing a single line of code, you need to choose a payment provider. Let's compare the two most popular options for SaaS founders:

Stripe: The Industry Standard

Stripe is the market leader in payment processing, offering comprehensive features and global reach.

Pros:

  • Extensive documentation and developer tools
  • Global payment coverage (40+ countries)
  • Supports virtually every payment method
  • Highly customizable
  • Robust subscription management
  • Used by companies of all sizes

Cons:

  • More complex setup
  • Requires more compliance considerations
  • Higher development overhead

Lemon Squeezy: The Simple Alternative

Lemon Squeezy is a newer, simplified payment solution specifically designed for digital products and SaaS.

Pros:

  • Much simpler implementation
  • Built-in sales tax handling
  • Faster setup time
  • All-in-one solution (payments + merchant of record)
  • Perfect for MVPs and solo founders
  • Lower compliance burden (they handle VAT/sales tax)

Cons:

  • Less customizable than Stripe
  • Fewer payment methods supported
  • Not as globally comprehensive
  • Limited advanced features

Integration Options Compared

Here's a quick comparison of what it takes to implement each provider:



javascript
// Stripe basic implementation
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Create a checkout session
const session = await stripe.checkout.sessions.create({
  payment_method_types: ['card'],
  line_items: [
    {
      price: 'price_1234567890',// Stripe Price ID
      quantity: 1,
    },
  ],
  mode: 'subscription',
  success_url: 'https://yourapp.com/success',
  cancel_url: 'https://yourapp.com/cancel',
});


javascript
// Lemon Squeezy basic implementation// No server-side code needed for basic checkout!// Just include their JavaScript and add a button:

<a
  href="https://yourstore.lemonsqueezy.com/checkout/buy/product_123abc"
  class="lemonsqueezy-button">
  Subscribe Now
</a>

Making Your Choice

For most MVP SaaS applications, I recommend:

  • Choose Lemon Squeezy if: You want the fastest implementation, need built-in tax handling, and have a straightforward pricing model with few custom requirements.
  • Choose Stripe if: You need extensive customization, have complex subscription logic, operate globally, or anticipate needing advanced features as you scale.

For this chapter, we'll cover both options, but with a slight emphasis on Stripe given its broader adoption.

Checkout (Button + API) 🛒

The checkout process is where your customers actually pay you. Let's explore how to implement checkout flows with both Stripe and Lemon Squeezy.

Stripe Checkout

Stripe Checkout provides a pre-built, hosted payment page that you can customize and integrate with your SaaS.

Step 1: Set up Products and Prices in Stripe Dashboard

Before coding, you need to create your products and prices in the Stripe Dashboard:

  1. Go to Products → Add Product
  1. Enter details (name, description)
  1. Add pricing (one-time or recurring)
  1. Save product and note the Price ID (e.g., price_1234567890)

Step 2: Create a Server Endpoint

Next, create an API endpoint in your Next.js app to create checkout sessions:



javascript
// app/api/create-checkout-session/route.js
import { NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST(request) {
  try {
    const { priceId, customerId } = await request.json();

// Create checkout session
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode: 'subscription',
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
      customer: customerId || undefined,
      customer_creation: customerId ? undefined : 'always',
    });

    return NextResponse.json({ url: session.url });
  } catch (error) {
    console.error('Stripe checkout error:', error);
    return NextResponse.json(
      { error: 'Failed to create checkout session' },
      { status: 500 }
    );
  }
}

Step 3: Create a Checkout Button Component

Now, create a client component that triggers the checkout process:



jsx
// components/CheckoutButton.jsx
"use client";
import { useState } from 'react';

export default function CheckoutButton({ priceId, customerId, text = "Subscribe" }) {
  const [loading, setLoading] = useState(false);

  async function handleCheckout() {
    setLoading(true);

    try {
      const response = await fetch('/api/create-checkout-session', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ priceId, customerId }),
      });

      const data = await response.json();

      if (data.url) {
        window.location.href = data.url;
      } else {
        console.error('Checkout error:', data.error);
        setLoading(false);
      }
    } catch (error) {
      console.error('Checkout error:', error);
      setLoading(false);
    }
  }

  return (
    <button
      onClick={handleCheckout}
      disabled={loading}
      className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
    >
      {loading ? "Loading..." : text}
    </button>
  );
}

Step 4: Use the Checkout Button in Your Pricing Page



jsx
// app/pricing/page.jsx
import CheckoutButton from '@/components/CheckoutButton';

export default function PricingPage() {
  return (
    <div className="container mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold text-center mb-12">Pricing Plans</h1>

      <div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
        {/* Basic Plan */}
        <div className="border rounded-lg p-6 flex flex-col">
          <h2 className="text-xl font-bold">Basic</h2>
          <p className="text-gray-600 mb-4">Perfect for getting started</p>
          <p className="text-3xl font-bold mb-6">$9<span className="text-lg font-normal">/month</span></p>
          <ul className="mb-8 flex-grow">
            <li className="mb-2">✅ Core Features</li>
            <li className="mb-2">✅ 5 Projects</li>
            <li className="mb-2">✅ Basic Support</li>
          </ul>
          <CheckoutButton
            priceId="price_1234567890"
            text="Start Basic Plan"
          />
        </div>

        {/* Other pricing tiers */}
      </div>
    </div>
  );
}

Lemon Squeezy Checkout

Lemon Squeezy offers an even simpler checkout integration.

Step 1: Set up Products in Lemon Squeezy Dashboard

  1. Create a Lemon Squeezy account
  1. Create a store
  1. Add a product with subscription variant
  1. Note your store ID and product variant IDs

Step 2: Option 1 - Direct Link Button (Simplest)

The easiest way to implement Lemon Squeezy is using their direct checkout links:



jsx
// components/LemonSqueezyButton.jsx
export default function LemonSqueezyButton({ variantId, text = "Subscribe" }) {
// Checkout URL format: https://[store-slug].lemonsqueezy.com/checkout/buy/[variant-id]
  const checkoutUrl = `https://yourstorename.lemonsqueezy.com/checkout/buy/${variantId}`;

  return (
    <a
      href={checkoutUrl}
      className="bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-4 rounded inline-block text-center"
    >
      {text}
    </a>
  );
}

Step 3: Option 2 - JavaScript Overlay Checkout

For a more integrated experience, use Lemon Squeezy's JavaScript API:



jsx
// components/LemonSqueezyOverlay.jsx
"use client";
import { useEffect } from 'react';
import Script from 'next/script';

export default function LemonSqueezyOverlay({ variantId, text = "Subscribe" }) {
  useEffect(() => {
// Initialize Lemon Squeezy JS when component mounts
    if (window.createLemonSqueezy) {
      window.createLemonSqueezy();
    }
  }, []);

  function handleClick() {
// Open the checkout overlay
    window.LemonSqueezy.Url.Open(`https://yourstorename.lemonsqueezy.com/checkout/buy/${variantId}`);
  }

  return (
    <>
      <Script
        src="https://assets.lemonsqueezy.com/lemon.js"
        strategy="afterInteractive"
        onLoad={() => window.createLemonSqueezy()}
      />

      <button
        onClick={handleClick}
        className="bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-4 rounded"
      >
        {text}
      </button>
    </>
  );
}

Step 4: Use in Your Pricing Page



jsx
// app/pricing/page.jsx
import LemonSqueezyOverlay from '@/components/LemonSqueezyOverlay';

export default function PricingPage() {
  return (
    <div className="container mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold text-center mb-12">Pricing Plans</h1>

      <div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
        {/* Basic Plan */}
        <div className="border rounded-lg p-6 flex flex-col">
          <h2 className="text-xl font-bold">Basic</h2>
          <p className="text-gray-600 mb-4">Perfect for getting started</p>
          <p className="text-3xl font-bold mb-6">$9<span className="text-lg font-normal">/month</span></p>
          <ul className="mb-8 flex-grow">
            <li className="mb-2">✅ Core Features</li>
            <li className="mb-2">✅ 5 Projects</li>
            <li className="mb-2">✅ Basic Support</li>
          </ul>
          <LemonSqueezyOverlay
            variantId="12345"
            text="Start Basic Plan"
          />
        </div>

        {/* Other pricing tiers */}
      </div>
    </div>
  );
}

Webhook (API) 📡

Webhooks are how payment providers communicate events to your application, such as successful payments, subscription updates, or cancellations. It's essential to implement webhooks to keep your database in sync with the payment provider's state.

Stripe Webhooks

Step 1: Create a Webhook Endpoint

First, create an API route to handle Stripe webhook events:



javascript
// app/api/webhooks/stripe/route.js
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { headers } from 'next/headers';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

export async function POST(request) {
  const body = await request.text();
  const headersList = headers();
  const signature = headersList.get('stripe-signature');

  if (!signature) {
    return NextResponse.json(
      { error: 'Missing stripe-signature header' },
      { status: 400 }
    );
  }

  let event;

  try {
// Verify the event came from Stripe
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error(`Webhook signature verification failed: ${err.message}`);
    return NextResponse.json(
      { error: 'Webhook signature verification failed' },
      { status: 400 }
    );
  }

// Handle specific events
  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutSessionCompleted(event.data.object);
        break;

      case 'customer.subscription.created':
      case 'customer.subscription.updated':
        await handleSubscriptionUpdated(event.data.object);
        break;

      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(event.data.object);
        break;

      case 'invoice.payment_failed':
        await handleInvoicePaymentFailed(event.data.object);
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error(`Webhook handler failed: ${error.message}`);
    return NextResponse.json(
      { error: 'Webhook handler failed' },
      { status: 500 }
    );
  }
}

// Event handler functions
async function handleCheckoutSessionCompleted(session) {
// Add customer to your database// The checkout session contains the customer ID and subscription ID
  console.log('Checkout completed:', session);

  const customerId = session.customer;
  const subscriptionId = session.subscription;

// Example: Update user in database// await db.user.update({//   where: { email: session.customer_email },//   data: {//     stripeCustomerId: customerId,//     stripeSubscriptionId: subscriptionId,//     plan: 'basic', // Determine from the product//     subscriptionStatus: 'active',//     subscriptionStartDate: new Date(),//   }// });
}

async function handleSubscriptionUpdated(subscription) {
// Update subscription details in your database
  console.log('Subscription updated:', subscription);

// Example: Update subscription in database// await db.subscription.update({//   where: { stripeSubscriptionId: subscription.id },//   data: {//     status: subscription.status,//     currentPeriodEnd: new Date(subscription.current_period_end * 1000),//     cancelAtPeriodEnd: subscription.cancel_at_period_end,//   }// });
}

async function handleSubscriptionDeleted(subscription) {
// Mark subscription as canceled in your database
  console.log('Subscription canceled:', subscription);

// Example: Update subscription in database// await db.subscription.update({//   where: { stripeSubscriptionId: subscription.id },//   data: {//     status: 'canceled',//     canceledAt: new Date(),//   }// });
}

async function handleInvoicePaymentFailed(invoice) {
// Handle failed payment (notify user, etc.)
  console.log('Payment failed:', invoice);

// Example: Send email notification to user// await sendEmail({//   to: invoice.customer_email,//   subject: 'Payment Failed',//   text: 'Your recent payment failed. Please update your payment method.'// });
}

Step 2: Set Up Stripe CLI for Testing

During development, use the Stripe CLI to forward webhooks to your local environment:

  1. Install the Stripe CLI
  1. Login to your Stripe account:
    
    
    bash
    stripe login
    
  1. Forward events to your local server:
    
    
    bash
    stripe listen --forward-to localhost:3000/api/webhooks/stripe
    
  1. Note the webhook signing secret that the CLI outputs and add it to your environment variables (STRIPE_WEBHOOK_SECRET)

Step 3: Set Up Webhook in Stripe Dashboard

For production, configure the webhook in your Stripe Dashboard:

  1. Go to Developers → Webhooks → Add Endpoint
  1. Enter your endpoint URL: https://yourapp.com/api/webhooks/stripe
  1. Select events to listen for (checkout.session.completed, customer.subscription.*)
  1. Get the signing secret and add it to your environment variables

Lemon Squeezy Webhooks

Setting up webhooks in Lemon Squeezy follows a similar pattern:

Step 1: Create a Webhook Endpoint



javascript
// app/api/webhooks/lemonsqueezy/route.js
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import crypto from 'crypto';

// Your webhook secret from Lemon Squeezy
const webhookSecret = process.env.LEMON_SQUEEZY_WEBHOOK_SECRET;

export async function POST(request) {
  const body = await request.text();
  const headersList = headers();
  const signature = headersList.get('x-signature');

  if (!signature) {
    return NextResponse.json(
      { error: 'Missing signature header' },
      { status: 400 }
    );
  }

// Verify the webhook signature
  const hmac = crypto.createHmac('sha256', webhookSecret);
  const digest = hmac.update(body).digest('hex');

  if (signature !== digest) {
    console.error('Invalid webhook signature');
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    );
  }

  const event = JSON.parse(body);
  const eventName = event.meta.event_name;
  const data = event.data;

// Handle specific events
  try {
    switch (eventName) {
      case 'order_created':
        await handleOrderCreated(data);
        break;

      case 'subscription_created':
      case 'subscription_updated':
        await handleSubscriptionUpdated(data);
        break;

      case 'subscription_cancelled':
        await handleSubscriptionCancelled(data);
        break;

      case 'subscription_payment_failed':
        await handleSubscriptionPaymentFailed(data);
        break;

      default:
        console.log(`Unhandled event type: ${eventName}`);
    }

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error(`Webhook handler failed: ${error.message}`);
    return NextResponse.json(
      { error: 'Webhook handler failed' },
      { status: 500 }
    );
  }
}

// Event handler functions
async function handleOrderCreated(data) {
  console.log('Order created:', data);

// Example: Update user in database// const userEmail = data.attributes.user_email;// const orderId = data.id;// const subscriptionId = data.attributes.first_subscription_item?.subscription_id;

// await db.user.update({//   where: { email: userEmail },//   data: {//     lemonSqueezyCustomerId: data.attributes.customer_id,//     lemonSqueezySubscriptionId: subscriptionId,//     plan: 'basic', // Determine from the product//     subscriptionStatus: 'active',//   }// });
}

async function handleSubscriptionUpdated(data) {
  console.log('Subscription updated:', data);

// Example: Update subscription in database// const subscriptionId = data.id;// const status = data.attributes.status;// const renewsAt = data.attributes.renews_at;

// await db.subscription.update({//   where: { lemonSqueezySubscriptionId: subscriptionId },//   data: {//     status: status,//     currentPeriodEnd: new Date(renewsAt),//     cancelAtPeriodEnd: data.attributes.ends_at !== null,//   }// });
}

async function handleSubscriptionCancelled(data) {
  console.log('Subscription cancelled:', data);

// Example: Update subscription in database// const subscriptionId = data.id;

// await db.subscription.update({//   where: { lemonSqueezySubscriptionId: subscriptionId },//   data: {//     status: 'cancelled',//     canceledAt: new Date(),//   }// });
}

async function handleSubscriptionPaymentFailed(data) {
  console.log('Payment failed:', data);

// Example: Send email notification to user// const userEmail = data.attributes.user_email;

// await sendEmail({//   to: userEmail,//   subject: 'Payment Failed',//   text: 'Your recent payment failed. Please update your payment method.'// });
}

Step 2: Set Up Webhook in Lemon Squeezy Dashboard

  1. Go to your Lemon Squeezy dashboard → Store Settings → Webhooks
  1. Click "Add Webhook"
  1. Enter your endpoint URL: https://yourapp.com/api/webhooks/lemonsqueezy
  1. Select the events you want to receive
  1. Copy the signing secret and add it to your environment variables

Portal (Button + API) 🔐

Customer billing portals allow your users to manage their subscriptions, update payment methods, view invoices, and cancel or upgrade their plans directly.

Stripe Customer Portal

Step 1: Set Up Stripe Customer Portal in Dashboard

Before implementing the portal, configure it in your Stripe Dashboard:

  1. Go to Settings → Customer Portal
  1. Configure branding, features, and products
  1. Set return URL (e.g., https://yourapp.com/account)

Step 2: Create an API Endpoint



javascript
// app/api/create-portal-session/route.js
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST(request) {
  try {
// Get the authenticated user
    const session = await getServerSession(authOptions);

    if (!session?.user) {
      return NextResponse.json(
        { error: 'You must be logged in' },
        { status: 401 }
      );
    }

// Get the user from your database
    const user = await db.user.findUnique({
      where: { email: session.user.email }
    });

    if (!user?.stripeCustomerId) {
      return NextResponse.json(
        { error: 'No billing information found' },
        { status: 404 }
      );
    }

// Create a Stripe portal session
    const portalSession = await stripe.billingPortal.sessions.create({
      customer: user.stripeCustomerId,
      return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
    });

    return NextResponse.json({ url: portalSession.url });
  } catch (error) {
    console.error('Stripe portal error:', error);
    return NextResponse.json(
      { error: 'Failed to create portal session' },
      { status: 500 }
    );
  }
}

Step 3: Create a Billing Portal Button Component



jsx
// components/BillingPortalButton.jsx
"use client";
import { useState } from 'react';

export default function BillingPortalButton() {
  const [loading, setLoading] = useState(false);

  async function handleManageBilling() {
    setLoading(true);

    try {
      const response = await fetch('/api/create-portal-session', {
        method: 'POST'
      });

      const data = await response.json();

      if (data.url) {
        window.location.href = data.url;
      } else {
        console.error('Portal error:', data.error);
        setLoading(false);
      }
    } catch (error) {
      console.error('Portal error:', error);
      setLoading(false);
    }
  }

  return (
    <button
      onClick={handleManageBilling}
      disabled={loading}
      className="text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
    >
      {loading ? "Loading..." : "Manage Billing"}
    </button>
  );
}

Step 4: Add the Button to Your Account Page



jsx
// app/account/page.jsx
import BillingPortalButton from '@/components/BillingPortalButton';

export default function AccountPage() {
  return (
    <div className="container mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">Account Settings</h1>

      <div className="bg-white rounded-lg shadow p-6 mb-6">
        <h2 className="text-xl font-semibold mb-4">Subscription</h2>
        <p className="mb-4">
          You are currently on the <span className="font-medium">Pro Plan</span>.
          Your next billing date is October 15, 2025.
        </p>
        <BillingPortalButton />
      </div>

      {/* Other account sections */}
    </div>
  );
}

Lemon Squeezy Customer Portal

Lemon Squeezy also offers a customer portal for managing subscriptions.

Step 1: Create a Portal Link Component



jsx
// components/LemonSqueezyPortal.jsx
"use client";
import { useState } from 'react';

export default function LemonSqueezyPortal({ customerId }) {
  const [loading, setLoading] = useState(false);

  async function handleManageBilling() {
    setLoading(true);

    try {
// Lemon Squeezy portal URL format: https://[store].lemonsqueezy.com/billing?customer=[customer_id]
      const portalUrl = `https://yourstorename.lemonsqueezy.com/billing?customer=${customerId}`;
      window.location.href = portalUrl;
    } catch (error) {
      console.error('Portal error:', error);
      setLoading(false);
    }
  }

  return (
    <button
      onClick={handleManageBilling}
      disabled={loading}
      className="text-yellow-600 hover:text-yellow-800 font-medium disabled:opacity-50"
    >
      {loading ? "Loading..." : "Manage Subscription"}
    </button>
  );
}

Step 2: Add the Button to Your Account Page



jsx
// app/account/page.jsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import LemonSqueezyPortal from '@/components/LemonSqueezyPortal';

export default async function AccountPage() {
  const session = await getServerSession(authOptions);

// Get the user with their customer ID from your database
  const user = await db.user.findUnique({
    where: { email: session.user.email }
  });

  return (
    <div className="container mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">Account Settings</h1>

      <div className="bg-white rounded-lg shadow p-6 mb-6">
        <h2 className="text-xl font-semibold mb-4">Subscription</h2>
        <p className="mb-4">
          You are currently on the <span className="font-medium">Pro Plan</span>.
        </p>
        {user.lemonSqueezyCustomerId && (
          <LemonSqueezyPortal customerId={user.lemonSqueezyCustomerId} />
        )}
      </div>

      {/* Other account sections */}
    </div>
  );
}

Putting It All Together 🧩

Now let's see how all these pieces work together to create a complete payment flow for your SaaS:

1. Database Schema

First, set up your database to track subscription information:



javascript
// prisma/schema.prisma (if using Prisma)
model User {
  id                 String    @id @default(cuid())
  email              String    @unique
  name               String?
  password           String
  createdAt          DateTime  @default(now())
  updatedAt          DateTime  @updatedAt

// Subscription fields
  stripeCustomerId   String?// For Stripe
  lemonSqueezyCustomerId String?// For Lemon Squeezy

  subscription       Subscription?
}

model Subscription {
  id                 String    @id @default(cuid())
  userId             String    @unique
  user               User      @relation(fields: [userId], references: [id], onDelete: Cascade)

// Subscription details
  providerId         String// "stripe" or "lemonsqueezy"
  providerSubscriptionId String @unique// External subscription ID
  status             String// "active", "canceled", "past_due", etc.
  plan               String// "basic", "pro", "enterprise", etc.
  priceId            String// External price ID

// Dates
  currentPeriodStart DateTime
  currentPeriodEnd   DateTime
  createdAt          DateTime  @default(now())
  canceledAt         DateTime?

// Flags
  cancelAtPeriodEnd  Boolean   @default(false)
}

2. Subscription Status Check

Create a utility to check subscription status:



javascript
// lib/subscription.js
export async function getUserSubscription(userId) {
// Fetch subscription from database
  const subscription = await db.subscription.findUnique({
    where: { userId }
  });

  if (!subscription) {
    return { isActive: false };
  }

// Check if subscription is active
  const isActive =
    subscription.status === 'active' ||
    subscription.status === 'trialing';

// Check if subscription is about to expire
  const isExpiring =
    subscription.cancelAtPeriodEnd &&
    new Date(subscription.currentPeriodEnd) > new Date();

// Calculate days remaining
  const daysRemaining = subscription.currentPeriodEnd
    ? Math.ceil

Retry

Claude hit the max length for a message and has paused its response. You can write Continue to keep the chat going.

KM

Continue

Edit



javascript
// lib/subscription.js (continued)// Calculate days remaining
 const daysRemaining = subscription.currentPeriodEnd
   ? Math.ceil((new Date(subscription.currentPeriodEnd) - new Date()) / (1000 * 60 * 60 * 24))
   : 0;

 return {
   isActive,
   status: subscription.status,
   plan: subscription.plan,
   isExpiring,
   daysRemaining,
   renewDate: subscription.currentPeriodEnd,
   providerId: subscription.providerId,
   providerSubscriptionId: subscription.providerSubscriptionId
 };
}

3. Middleware for Protected Routes

Create middleware to protect pages that require an active subscription:



javascript
// middleware.js
import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';

export async function middleware(request) {
// Get the pathname
  const path = request.nextUrl.pathname;

// Paths that require authentication and active subscription
  const protectedPaths = ['/dashboard', '/app', '/settings'];
  const isProtectedPath = protectedPaths.some(pp => path.startsWith(pp));

  if (!isProtectedPath) {
    return NextResponse.next();
  }

// Check if user is authenticated
  const token = await getToken({ req: request });
  const isAuthenticated = !!token;

// If not authenticated, redirect to login
  if (!isAuthenticated) {
    const redirectUrl = new URL('/login', request.url);
    redirectUrl.searchParams.set('callbackUrl', path);
    return NextResponse.redirect(redirectUrl);
  }

// If authenticated, check subscription status// (This would typically be stored in the token or checked against the database)
  const hasActiveSubscription = token.subscription?.isActive;

// If no active subscription, redirect to billing page
  if (!hasActiveSubscription && path !== '/billing') {
    return NextResponse.redirect(new URL('/billing', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

4. Complete Pricing Page

Create a full pricing page with both plan options and checkout buttons:



jsx
// app/pricing/page.jsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import StripeCheckoutButton from '@/components/StripeCheckoutButton';
import { getUserSubscription } from '@/lib/subscription';

// Define pricing plans
const plans = [
  {
    name: 'Basic',
    description: 'Perfect for getting started',
    price: 9,
    features: [
      'Core Features',
      '5 Projects',
      'Basic Support',
      '1GB Storage'
    ],
    stripePriceId: 'price_1234567890',
    popular: false
  },
  {
    name: 'Pro',
    description: 'For growing businesses',
    price: 29,
    features: [
      'All Basic Features',
      'Unlimited Projects',
      'Priority Support',
      '10GB Storage',
      'Advanced Analytics'
    ],
    stripePriceId: 'price_0987654321',
    popular: true
  },
  {
    name: 'Enterprise',
    description: 'For large teams',
    price: 99,
    features: [
      'All Pro Features',
      'Dedicated Support',
      '100GB Storage',
      'Custom Integrations',
      'Team Management'
    ],
    stripePriceId: 'price_5432109876',
    popular: false
  }
];

export default async function PricingPage() {
  const session = await getServerSession(authOptions);
  const isLoggedIn = !!session?.user;

// If user is logged in, get their data
  let user = null;
  let subscription = null;

  if (isLoggedIn) {
    user = await db.user.findUnique({
      where: { email: session.user.email }
    });

    subscription = await getUserSubscription(user.id);
  }

// Check if a success or canceled URL parameter exists
  const searchParams = new URL(request.url).searchParams;
  const success = searchParams.get('success');
  const canceled = searchParams.get('canceled');

  return (
    <div className="container mx-auto px-4 py-16">
      <div className="text-center mb-16">
        <h1 className="text-4xl font-bold mb-4">Simple, Transparent Pricing</h1>
        <p className="text-lg text-gray-600 max-w-2xl mx-auto">
          Choose the plan that works best for you. All plans include a 14-day free trial.
        </p>
      </div>

      {success && (
        <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-8 max-w-3xl mx-auto">
          <p className="font-medium">Thanks for subscribing! Your account has been upgraded.</p>
        </div>
      )}

      {canceled && (
        <div className="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-8 max-w-3xl mx-auto">
          <p>Your checkout was canceled. If you have any questions, please contact support.</p>
        </div>
      )}

      <div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
        {plans.map((plan) => {
          const isCurrentPlan = subscription?.isActive && subscription.plan.toLowerCase() === plan.name.toLowerCase();

          return (
            <div
              key={plan.name}
              className={`bg-white rounded-lg shadow-lg overflow-hidden border-2 ${
                plan.popular ? 'border-blue-500 relative md:scale-105' : 'border-transparent'
              }`}
            >
              {plan.popular && (
                <div className="bg-blue-500 text-white text-center py-1 text-sm font-medium">
                  Most Popular
                </div>
              )}

              <div className="p-6">
                <h3 className="text-2xl font-bold">{plan.name}</h3>
                <p className="text-gray-600 mb-4">{plan.description}</p>
                <p className="text-4xl font-bold mb-6">
                  ${plan.price}<span className="text-lg font-normal text-gray-600">/month</span>
                </p>

                <ul className="mb-8 space-y-3">
                  {plan.features.map((feature, i) => (
                    <li key={i} className="flex items-start">
                      <svg
                        className="h-5 w-5 text-green-500 mr-2 mt-0.5"
                        fill="none"
                        viewBox="0 0 24 24"
                        stroke="currentColor"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          strokeWidth={2}
                          d="M5 13l4 4L19 7"
                        />
                      </svg>
                      {feature}
                    </li>
                  ))}
                </ul>

                {isCurrentPlan ? (
                  <div className="bg-green-100 text-green-800 text-center py-2 rounded-md">
                    Your Current Plan
                  </div>
                ) : (
                  <StripeCheckoutButton
                    priceId={plan.stripePriceId}
                    customerId={user?.stripeCustomerId}
                    text={subscription?.isActive ? 'Change Plan' : 'Start Free Trial'}
                    className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-md"
                  />
                )}
              </div>
            </div>
          );
        })}
      </div>

      <div className="mt-16 text-center max-w-3xl mx-auto">
        <h3 className="text-2xl font-bold mb-4">Frequently Asked Questions</h3>

        <div className="space-y-6 text-left">
          <div className="bg-white p-6 rounded-lg shadow">
            <h4 className="font-bold text-lg mb-2">Can I cancel anytime?</h4>
            <p className="text-gray-600">Yes, you can cancel your subscription at any time. You'll continue to have access until the end of your current billing period.</p>
          </div>

          <div className="bg-white p-6 rounded-lg shadow">
            <h4 className="font-bold text-lg mb-2">How does the free trial work?</h4>
            <p className="text-gray-600">Your 14-day free trial gives you full access to all features of your chosen plan. No credit card is required to start the trial.</p>
          </div>

          <div className="bg-white p-6 rounded-lg shadow">
            <h4 className="font-bold text-lg mb-2">Do you offer refunds?</h4>
            <p className="text-gray-600">We offer a 30-day money-back guarantee. If you're not satisfied with our service, contact support within 30 days for a full refund.</p>
          </div>
        </div>
      </div>
    </div>
  );
}

5. Account Page with Subscription Management

Create an account page that shows subscription details and allows users to manage their billing:



jsx
// app/account/page.jsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import { getUserSubscription } from '@/lib/subscription';
import BillingPortalButton from '@/components/BillingPortalButton';
import Link from 'next/link';

export default async function AccountPage() {
  const session = await getServerSession(authOptions);

  if (!session) {
    redirect('/login');
  }

// Get user and subscription details
  const user = await db.user.findUnique({
    where: { email: session.user.email }
  });

  const subscription = await getUserSubscription(user.id);

// Format dates
  const formatDate = (date) => {
    return date ? new Date(date).toLocaleDateString('en-US', {
      year: 'numeric',
      month: 'long',
      day: 'numeric'
    }) : 'N/A';
  };

// Determine subscription status text and color
  let statusText = 'No active subscription';
  let statusColor = 'text-gray-500';

  if (subscription.isActive) {
    statusText = subscription.status === 'trialing'
      ? 'Trial Active'
      : 'Active';
    statusColor = 'text-green-600';
  } else if (subscription.status === 'past_due') {
    statusText = 'Past Due';
    statusColor = 'text-red-600';
  } else if (subscription.status === 'canceled') {
    statusText = 'Canceled';
    statusColor = 'text-yellow-600';
  }

  return (
    <div className="container mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">Account Settings</h1>

      <div className="grid md:grid-cols-3 gap-8">
        <div className="md:col-span-2">
          {/* Personal Information Section */}
          <div className="bg-white rounded-lg shadow p-6 mb-6">
            <h2 className="text-xl font-semibold mb-4">Personal Information</h2>
            <div className="grid md:grid-cols-2 gap-4">
              <div>
                <p className="text-sm text-gray-600 mb-1">Name</p>
                <p className="font-medium">{user.name || 'Not set'}</p>
              </div>
              <div>
                <p className="text-sm text-gray-600 mb-1">Email</p>
                <p className="font-medium">{user.email}</p>
              </div>
            </div>
            <div className="mt-4">
              <Link
                href="/account/edit"
                className="text-blue-600 hover:text-blue-800 font-medium"
              >
                Edit Profile
              </Link>
            </div>
          </div>

          {/* Subscription Section */}
          <div className="bg-white rounded-lg shadow p-6">
            <div className="flex justify-between items-center mb-4">
              <h2 className="text-xl font-semibold">Subscription</h2>
              <span className={`font-medium ${statusColor}`}>
                {statusText}
              </span>
            </div>

            {subscription.isActive ? (
              <div>
                <div className="grid md:grid-cols-2 gap-4 mb-6">
                  <div>
                    <p className="text-sm text-gray-600 mb-1">Plan</p>
                    <p className="font-medium capitalize">{subscription.plan}</p>
                  </div>

                  <div>
                    <p className="text-sm text-gray-600 mb-1">Renewal Date</p>
                    <p className="font-medium">
                      {formatDate(subscription.renewDate)}
                      {subscription.isExpiring && (
                        <span className="ml-2 text-yellow-600 text-sm">
                          (Will not renew)
                        </span>
                      )}
                    </p>
                  </div>
                </div>

                {/* If subscription is ending, show a warning */}
                {subscription.isExpiring && (
                  <div className="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
                    <p className="text-yellow-800">
                      Your subscription will end on {formatDate(subscription.renewDate)}.
                      You'll lose access to premium features after this date.
                    </p>
                  </div>
                )}

                <div className="flex space-x-4">
                  <BillingPortalButton />

                  {subscription.isExpiring && (
                    <Link
                      href="/pricing"
                      className="text-blue-600 hover:text-blue-800 font-medium"
                    >
                      Renew Subscription
                    </Link>
                  )}
                </div>
              </div>
            ) : (
              <div>
                <p className="mb-4">You don't have an active subscription.</p>
                <Link
                  href="/pricing"
                  className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md inline-block"
                >
                  View Plans
                </Link>
              </div>
            )}
          </div>
        </div>

        {/* Sidebar */}
        <div>
          <div className="bg-white rounded-lg shadow p-6">
            <h2 className="text-xl font-semibold mb-4">Account Security</h2>
            <Link
              href="/account/password"
              className="text-blue-600 hover:text-blue-800 font-medium block mb-3"
            >
              Change Password
            </Link>
            <Link
              href="/account/security"
              className="text-blue-600 hover:text-blue-800 font-medium block"
            >
              Security Settings
            </Link>
          </div>
        </div>
      </div>
    </div>
  );
}

Best Practices for SaaS Payments 💼

Security Considerations

  1. Never store raw credit card data - Let your payment provider handle sensitive payment information
  1. Use HTTPS everywhere - Secure all payment-related pages and APIs
  1. Validate webhook signatures - Always verify that webhook events are coming from your payment provider
  1. Use environment variables for API keys and secrets
  1. Implement proper error handling - Never expose sensitive error details to users

User Experience

  1. Simple pricing tiers - Make your pricing easy to understand
  1. Clear feature comparison - Show users what they get with each plan
  1. Seamless checkout flow - Minimize friction in the payment process
  1. Transparent billing information - Make it easy for users to understand what they're being charged for
  1. Smooth cancellation process - Don't make it difficult to cancel (but do try to save the customer!)

Business Considerations

  1. Start with annual and monthly options - Annual plans improve cash flow and reduce churn
  1. Consider offering a free tier or trial - Let users experience value before paying
  1. Implement dunning management - Automatically recover failed payments with retry logic
  1. Track key metrics - Monitor MRR, churn, LTV, and conversion rates
  1. Test pricing regularly - Experiment with different price points and packaging

Technical Implementation

  1. Keep your database in sync with payment provider events
  1. Implement idempotency - Handle webhook events that may be delivered multiple times
  1. Create fallback mechanisms - Have a plan for when payment providers experience downtime
  1. Log all payment events for debugging and audit purposes
  1. Use feature flags to control access to paid features

Practice Exercises 🏋️

  1. Basic Checkout Implementation:
    • Set up a Stripe or Lemon Squeezy account
    • Create a simple pricing page with checkout buttons
    • Implement the server endpoint to create checkout sessions
    • Test the checkout flow with test mode
  1. Webhook Challenge:
    • Create a webhook endpoint that listens for subscription events
    • Implement handlers for subscription creation, updates, and cancellations
    • Update a database record when events occur
    • Test with Stripe CLI or webhook replay
  1. Subscription Management:
    • Build a user account page that displays subscription details
    • Add a billing portal button that redirects to the provider's portal
    • Show different UI states based on subscription status
    • Implement upgrade/downgrade functionality
  1. Payment Analytics Dashboard:
    • Create a dashboard to track key payment metrics
    • Implement charts for MRR, active subscriptions, and churn
    • Add filters for date ranges and plans
    • Display customer lifetime value calculations

Pro Tip: Start with the simplest implementation that works (like Lemon Squeezy's direct checkout links) and gradually add complexity as your business needs grow. Don't overengineer your payment system before you've validated your business model.

What You've Learned ✅

  • The key differences between Stripe and Lemon Squeezy and when to use each
  • How to implement checkout flows to accept payments
  • Setting up webhooks to keep your database in sync with payment events
  • Creating customer billing portals for subscription management
  • Best practices for SaaS payment processing

Coming up next: In Chapter 23, we'll dive into Security 101, covering essential security practices to protect your SaaS application and your users' data.

Blog CTA Snippet

Don't Have Time to Build Your SaaS?

Learning to code is powerful — but your time is better spent growing your business. Let our expert team handle your MVP while you focus on what matters.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top