Published on

Integrating Chargebee with Next.js: A Step-by-Step Guide

Authors

If you are building a business that relies on subscriptions, like a software as a service (SaaS) app, you may have found that creating and maintaining your own subscription system can be very complex and time-consuming, especially if you do not have much experience in this area.

Managing subscriptions involves more than just knowing when to charge. You also need to take care of things like creating legally compliant invoices, providing coupons, retrying failed payments, and integrating with third-party services like accounting and reporting. This can be especially challenging if you need to use multiple payment gateways.

Chargebee is a platform that can help with these tasks. It simplifies the process of managing subscriptions, so you can focus on your core business.

In this article, we will look at how to integrate Chargebee with Next.js, a JavaScript framework. Specifically, we will build a page that allows users to subscribe to plans and manage their subscriptions.

What we'll be doing?

  1. Create / Update a subscription
  2. Listen to changes via webhooks and update it database
  3. Show the current subscription details in UI

What's not covered?

Please note that this article will not cover building a fancy user interface, using authentication or security measures, or following best practices for writing code. The goal is to provide a simple guide for getting started with Chargebee, without overwhelming you with complexity.

Step 0: Dependencies

We'll be primarily using Chargebee's Node.js library for interacting with the Chargebee in our backend.

  npx create-next-app@latest 
  # then cd to your directory
  npm install chargebee mongodb mongoose

We'll also need Chargebee API key and site name. You can find these in the Chargebee dashboard under "Settings > API & Webhooks".

Once you have your API key and site name, create a file called .env in the root directory of your Next.js project. Add the following lines to the file, replacing YOUR_API_KEY and YOUR_SITE_NAME with your own values:

CHARGEBEE_API_KEY=YOUR_API_KEY
NEXT_PUBLIC_CHARGEBEE_DOMAIN=YOUR_SITE_NAME
DATABASE_URL=mongodb://username:password@host:port

Database Schema for subscription

we will be setting up a database schema to store subscription information, rather than constantly requesting this data from Chargebee's API. We will use Chargebee's webhooks to keep our data up to date. The database we will be using is MongoDB. If you do not already have MongoDB, we recommend using Railway.app to create a temporary database without even signing up (this database will be automatically deleted after 24 hours).

Connecting to DB from our serverless function

Since Next.js uses serverless functions, it is important to ensure that we are connected to our database before performing any operations.

So we'll be calling dbConnect function before interacting with db everywhere.

// server/config/database.js
// https://github.com/vercel/next.js/blob/canary/examples/with-mongodb-mongoose/lib/dbConnect.js
import mongoose from 'mongoose'

const DATABASE_URL = process.env.DATABASE_URL

if (!DATABASE_URL) {
  throw new Error(
    'Please define the DATABASE_URL environment variable inside .env.local'
  )
}

/**
 * Global is used here to maintain a cached connection across hot reloads
 * in development. This prevents connections growing exponentially
 * during API Route usage.
 */
let cached = global.mongoose

if (!cached) {
  cached = global.mongoose = { conn: null, promise: null }
}

async function dbConnect() {
  if (cached.conn) {
    return cached.conn
  }

  if (!cached.promise) {
    const opts = {
      bufferCommands: false,
    }

    cached.promise = mongoose.connect(DATABASE_URL, opts).then((mongoose) => {
      return mongoose
    })
  }

  try {
    cached.conn = await cached.promise
  } catch (e) {
    cached.promise = null
    throw e
  }

  return cached.conn
}

export default dbConnect

Database Schema

Here is our database schema

// server/models/Subscription.js
const mongoose = require("mongoose");
const { Schema } = mongoose;
import chargebee from "chargebee";
import { initChargebee } from 'server/config/chargebee'

initChargebee()

const subscriptionSchema = new Schema(
  {
    // 👉 Ideally, userId schema will look like below.
    // Since we're not building auth I've commented it our and using plain string instead.
    userId: {
      // type: mongoose.Schema.Types.ObjectId,
      // require: "Please input userId",
      // ref: "User",
      type: String,
      default: null
    },
    chargebeeCustomerId: {
      type: String,
      default: null,
    },
    chargebeeSubscriptionId: {
      type: String,
      default: null,
    },
    chargebeeSubscriptionStatus: {
      type: String,
      default: null,
    },
    chargebeePlanId: {
      type: String,
      default: null,
    },
    metaData: {
      type: Object,
      default: {},
    },
  },
  {
    toJSON: { virtuals: true },
    toObject: { virtuals: true },
  }
);

class SubscriptionClass extends mongoose.Model {
  static async getCurrentUserSubscription({ userId }) {
    return this.findOne({ userId }).setOptions({
      lean: true,
    });
  }

  static async updateCbCustomer(userId, payload = {}) {
    console.log({ fn: 'updateCbCustomer', userId, payload });

    const modified = {
      chargebeeCustomerId: payload?.customer?.id,
    };

    // updateOne create a new record if not available (option: {upsert: true})
    return this.updateOne(
      { userId },
      modified,
      { upsert: true, runValidators: true }
    ).setOptions({ lean: true });
  }

  static async update(userId, payload = {}) {
    console.log({ fn: 'update', userId, payload });
    const modified = {
      ...payload,
    };
    // updateOne create a new record if not available (option: {upsert: true})
    return this.updateOne(
      { userId },
      modified,
      { upsert: true, runValidators: true }
    ).setOptions({ lean: true });
  }

  static async handleWebooks(payload) {
    try {

      let modified = {};
      const context = payload?.content;
      const eventType = payload?.event_type;
      switch (eventType) {
        case "customer_created":
        case "customer_deleted":
          await this.updateCbCustomer(context?.customer?.id, {
            customer: context?.customer,
          });
          break;
        case "subscription_created":
        case "subscription_cancelled":
        case "subscription_changed":
        case "subscription_renewed": {
          const subscription = context?.subscription;
          modified.chargebeeSubscriptionId = subscription.id;
          modified.chargebeeSubscriptionStatus = subscription.status;
          if (
            subscription.subscription_items &&
            subscription.subscription_items.length
          ) {
            modified.chargebeePlanId =
              context?.subscription.subscription_items[0].item_price_id;
          }
          if (subscription?.meta_data) {
            modified.metaData = subscription.meta_data;
          } else {
            modified.metaData = {};
            modified.metaData.allowedUsers = 5;
          }

          if (eventType === "subscription_created") {

            await chargebee.subscription
              .update_for_items(subscription?.id, {
                meta_data: {
                  allowedUsers: 5,
                },
              })
              .request();
          }
          const reply = await this.update(context?.customer?.id, modified);
          console.log(reply);
          break;
        }
        default:
          break;
      }
    } catch (error) {
      throw error;
    }
  }
}

subscriptionSchema.loadClass(SubscriptionClass);

export default mongoose.models.Subscription ||
  mongoose.model("Subscription", subscriptionSchema);

Configuring Chargebee SDK

Just like we need to initialize the database before interacting with it, we also need to initialize the Chargebee configuration before we can use the Chargebee SDK.

We've also created a config file for that which also has Chargebee Origin domains array that we can use to validate incoming webhook request later on.

// server/config/chargebee.js
import chargebee from "chargebee";

export const initChargebee = () => {
    chargebee.configure({
        site: process.env.NEXT_PUBLIC_CHARGEBEE_DOMAIN,
        api_key: process.env.CHARGEBEE_API_KEY,
    });
}

// https://apidocs.chargebee.com/docs/api/?prod_cat_ver=2#ip-addresses
export const CHARGEBEE_WEBHOOKS_REQUEST_ORIGINS = [
    "3.209.65.25",
    "3.215.11.205",
    "3.228.118.137",
    "3.229.12.56",
    "3.230.149.90",
    "3.231.198.174",
    "3.231.48.173",
    "3.233.249.52",
    "18.210.240.138",
    "18.232.249.243",
    "34.195.242.184",
    "34.206.183.55",
    "35.168.199.245",
    "52.203.173.152",
    "52.205.125.34",
    "52.45.227.101",
    "54.158.181.196",
    "54.163.233.122",
    "54.166.107.32",
    "54.84.202.184",
];

Creating Billing API Endpoints

In terms of API, we'll have two API endpoints one to create/update subscription and another to get the subscription details from our database.

Fetching Subscription details API

// pages/api/billing/index.js
import Subscription from "server/models/Subscription";
import dbConnect from 'server/config/database'

export default async function handler(req, res) {
    await dbConnect()
    // We're mocking userId below. Ideally, get it from your auth module
    const userId = req.query?.userId || 'example_user_id'
    const subscription = await Subscription.getCurrentUserSubscription({
        userId,
    })
    res.json({ subscription });
}

Create/Update Subscription API

// /api/billing/generate_checkout_url.js
import chargebee from "chargebee";
import { initChargebee } from 'server/config/chargebee'

initChargebee()
export default async function handler(req, res) {
    // Ideally, we'll get customer details from our auth module.
    // Here we're hard coding for our convenience since we're not implementing auth module.


    const { planId } = req.query;
    const userId = req.query?.userId || 'example_user_id'

    const payload = {
        subscription: {
            id: userId,
        },
        subscription_items: [
            {
                item_price_id: planId,
                quantity: 1,
            },
        ],
        customer: {
            id: userId,
            email: `${userId}@example.com`
        },
    };

    let hasSubscription = false;

    try {
        const { subscription: currentSubscription } = await chargebee.subscription
            .retrieve(userId)
            .request();

        hasSubscription = Boolean(currentSubscription?.id);
    } catch (error) {
        // Do nothing
    }

    try {
        if (!hasSubscription) {
            // New Checkout
            const result = await chargebee.hosted_page
                .checkout_new_for_items(payload)
                .request();
            res.send(result?.hosted_page);
        } else {
            // Update Checkout
            const result = await chargebee.hosted_page
                .checkout_existing_for_items(payload)
                .request();
            res.send(result?.hosted_page);
        }
    } catch (error) {
        console.log(error);
        res.status(500).json({ errorMessage: 'Something went down in billing' })
        throw error;
    }
}

Creating webhooks API

We'll have a webhooks API which we'll configure in Chargebee.

This bascially listens to all/custom events from the Chargebee and we can use that data to we can keep our app up to data based on that data.

// pages/api/webhooks.js
import { CHARGEBEE_WEBHOOKS_REQUEST_ORIGINS } from 'server/config/chargebee'
import Subscription from "server/models/Subscription";
import dbConnect from 'server/config/database'

export default async function handler(req, res) {
    try {
        const requestIp =
            req.headers["x-real-ip"] || req.headers["x-forwarded-for"];

        if (
            (requestIp &&
                CHARGEBEE_WEBHOOKS_REQUEST_ORIGINS.find((ip) => ip === requestIp))
        ) {
            await dbConnect()
            await Subscription.handleWebooks(req.body);
            res.send("ok");
        } else {
        }
        console.log("we have the webhook!");
    } catch (error) {
        throw error;
    }
}

And the UI

// pages/index.js
import { useEffect, useState } from "react"
import Script from "next/script";



const containerBoxStyle = { border: 'solid 2px black', maxWidth: '500px', padding: '16px', margin: '16px' }
// Depending on the use case. We might get this from API instead
const plans = {
  basicMonthly: 'cbdemo_basic-USD-monthly',
  basicYearly: 'cbdemo_basic-USD-yearly'
}

export default function Home() {
  const [subscription, setSubscription] = useState(null)
  const [isLoading, setIsLoading] = useState(true)
  const [cbInstance, setCbInstance] = useState(null);

  useEffect(() => {
    let params = (new URL(document.location)).searchParams;
    const queryString = {}
    if (params.get("userId")) {
      queryString.userId = params.get("userId")
    }
    const qs = Object.keys(queryString).map(key => {
      return `${key}=${encodeURIComponent(queryString[key])}`;
    }).join('&');

    fetch(`/api/billing?${qs}`).then(res => res.json()).then(data => {
      setSubscription(data?.subscription)
      setIsLoading(false)
    })
  }, [setSubscription])

  const handleCheckout = (planId) => {
    if (typeof window !== 'undefined') {
      if (!cbInstance && window.Chargebee) {
        setCbInstance(
          window.Chargebee.init({
            site: process.env.NEXT_PUBLIC_CHARGEBEE_DOMAIN,
          })
        );
        return;
      }
      cbInstance?.openCheckout({
        hostedPage: async () => {

          let params = (new URL(document.location)).searchParams;

          const queryString = {
            planId,
          }

          if (params.get("userId")) {
            queryString.userId = params.get("userId")
          }

          const qs = Object.keys(queryString).map(key => {
            return `${key}=${encodeURIComponent(queryString[key])}`;
          }).join('&');


          const data = await (await fetch(`/api/billing/generate_checkout_url?${qs}`)).json()
          return data;
        },
        success(hostedPageId) {
          alert("Successfully created/updated subscription. It'll take sometime to update in the app")
        },
        close: () => {
          console.log("checkout new closed");
        },
        step(step) {
          console.log("checkout", step);
        },
      });
    }
  };

  const hasSubscription = !isLoading && subscription !== null

  return (
    <>
      <div style={containerBoxStyle}>
        <h2>Current Subscription Record</h2>
        <span>To set custom <b>userId</b>. You can add userId query string (eg: ?userId=example)</span>
        <pre>{isLoading ? 'fetching data from api' : JSON.stringify(subscription, null, 4)}</pre>
      </div>
      <div style={containerBoxStyle}>
        <h2>Subscribe / Upgrade your Subscription</h2>
        <button onClick={() => handleCheckout(hasSubscription ? plans.basicYearly : plans.basicMonthly)}> {hasSubscription ? 'Upgrade' : 'Subscribe'}</button>
      </div>
      <Script
        src="https://js.chargebee.com/v2/chargebee.js"
        onLoad={() => {
          setCbInstance(
            window.Chargebee.init({
              site: process.env.NEXT_PUBLIC_CHARGEBEE_DOMAIN,
            })
          );
        }}
      />
    </>
  )
}

Sample repo and demo

I've created a sample repo for you to playaround :)

https://github.com/AshikNesin/chargebee-with-nextjs

Disclaimer

I work at Chargebee, but this isn't an official recommendation or anything. I am simply documenting the steps that I took while integrating Chargebee with my side project, qsite.co.