How we deal with plan limits in the front end of our SaaS app

If you run a SaaS, you probably want to show your users when they are almost running out of widgets. Or that they can get some cool feature on a more expensive plan. Or, in other words, how can you be nice and commercial in dealing with plan limits.

Last week we already looked at how we manage plans & features for Checkly. That write up was very back end focused, so this week I wanted to dive deeper into how we actually show this to our users in a friendly way.

We use Vue.js with Vuex for our front end, but the patterns and code examples here can be applied to any other SPA framework.

Types of plan limits

Short recap of the types of plan limits we recognized in the last writeup.

  1. Paying vs. lapsing: You are a paying customer or on a trial / stopped paying.
  2. Plan based feature toggles: A feature is enabled on your plan or not.
  3. Plan based volume limits: You are allowed ten of these and five of those.

We also mentioned role based access control but I wanted to keep that for another write up.

The basic setup

We need to keep track of a bunch of fairly global variables, some fairly static — plan expiry date for yearly payers changes once a year— some that dynamically change as the user interacts with the app.

However, we do not want to bother all of our frontend components with the logic to control and validate these cross cutting concerns. We want to expose a dedicated — dare I say singleton — object that encapsulates the current state of all of plan and user logic.

For this we use Vue.js and Vuex, a Redux-type central data store. On initial page load, we populate an object using actions and mutations (two very specific Vuex things I won't go into much deeper here) with the things we are interested in.

Or, in pre-Javascript-frameworks-are-eating-the-world-speak, you fire an XHR request when a user logs in, your backend returns all account data, you parse it into a palatable object.

Here is what such an object looks like. It's an almost exact copy & paste from the excellent Vue.js debug tool.

    isPayingCustomer: true,
    currentAccount: {
        features: ['SMS_ALERTS', 'TEAMS', 'PROMETHEUS', 'TRIGGERS']
    expiryStatus: {
        daysTillTrialExpiry: 24
        planHasExpired: false
    isFeatureLimited: {
        accountUsers: true
        apiChecks: true
        browserChecks: false
        dashboards: false

Notice a couple of things:

  1. We transform almost all properties into isSomething or hasSomething forms. This makes your code nicer in the components that use this later.
  2. We have a currentAccount object because a user can be a member of multiple accounts and can switch between them during a session.
  3. Strictly speaking, the expiryStatus object holds superfluous data. But we don't want every component that uses this to implement the boolean function planHasExpired based on the daysTillTrialExpiry property.
  4. This representation is pretty different from how we store it on our backend. It is specifically tailored to be useful in the frontend.

That last bullet is kinda important, I figured out after a while. Here comes a quote:

How things are stored on your backend should never determine how you use them in your frontend.

This is probably material for another post, but very essential to self starting, full stack developers. You need to cross the chasm. Backend and frontend are not the same.

Let's look at some examples now.

Example 1: Plan expiry nag screen

This is what appears at the top of you navigation bar in Checkly if you are dangerously close to your plan expiring. This happens only on two occasions:

  1. You are a trial user and haven't upgraded yet.
  2. You are a paying member of our secret and exclusive society, but for some unspoken reason your credit card failed.
Example of plan expiry nag message

To conjure up this message, we use the following code. Note we use  Jade/Pug for templating but it should translate to plain HTML quite easily.

    | You have only {{expiryStatus.daysTillTrialExpiry}} day(s) left in your trial!
    router-link(:to="{ name: 'billing:plans' }") Upgrade your plan
    | Your trial has expired!
    router-link(:to="{ name: 'billing:plans' }") Upgrade your plan

Two things are happening here:

  1. We have an if statement on the showUpgradeTeaser and showExpiredTeaser booleans. If they are false, we don't show 'm. You get it.
  2. We directly use the expiryStatus object and tap into the daysTillTrialExpiry property to let the user know how long he/she has.

But how do we get this data from the central data store? And how do we set that showUpgradeTeaser property? For this we leverage Vue.js's computed properties. They are absolutely awesome and I use them as much as I can.

Simply put, they are properties they are constantly updated based in changing inputs. "Reactive" if you will. In most frameworks, this code lives in the controller of your frontend component, although Vue.js does not call them that.

Here's a look at a part of the code of our navigation bar component.

  computed: {
    expiryStatus() {
    showUpgradeTeaser () {
      return this.expiryStatus 
        ? (this.expiryStatus.daysTillTrialExpiry > 0 
        && this.expiryStatus.daysTillTrialExpiry < 5) : false
    showExpiredTeaser () {
      return this.expiryStatus ? this.expiryStatus.planHasExpired : false

You can see how the showUpgradeTeaser and showExpiredTeaser are created. They directly tap into the expiryStatus object, which is exposed to the local this context by a very Vue.js specific way of getting data from a Vuex store. Your framework will have a similar thing. Also notice we start show the upgrade teaser from the last five days till a plan expires.

Example 2: plan volume limit reached

This is what a user sees when they try to create one more check when they already are at their plan limit.

Example of plan volume message

We explicitly want a user to be notified about his/her plan limit the moment that creating a new check is relevant. There is probably a very good commercial reason for that and that is why all SaaS companies do it [citation needed].

Here's a snippet of our frontend code. It follows the exact same pattern as the example above:

.dropdown-item(v-if='isFeatureLimited.apiChecks ||  expiryStatus.planHasExpired')
    .title API check
        router-link(:to="{ name: 'billing:plans' }") Upgrade your plan
        .button-text You maxed out the API checks in your account.

Again, it taps into the expiryStatus object but this time also into the isFeatureLimited object. Together, they decide whether to show the upgrade button (and block creating a new check) or not.

The isFeatureLimited object encapsulates the state of a plan and if it is over its assigned volume limits for a specific resource; in our case API checks and browser checks.

This is actually a bit more complicated than it seems. We, again, deal with it in our central data store. Here's a snippet:

  isFeatureLimited: (state, getters) => {
    return {
      apiChecks: getters.checks.filter(check => {
        return check.checkType === 'API'
      }).length >= getters.currentAccount.maxApiChecks

The property apiChecks is dynamically generated based on two other properties in our data store:

  1. checks, an array of all checks which we first filter on check type and then count. Add a check or remove a check and this gets updated on the fly.
  2. currentAccount.maxApiChecks, a property determined by the plan the user is currently on. Upgrade and you get more, automatically bumping this value.

We do the exact same thing for all other volume limited resources like browser checks, team members and dashboards.

Example 3: Plan feature toggle

Here's what you see when your plan does not have a specific feature, in this case the Pagerduty integration which is not in our Developer plan.

Example of plan feature toggle message

This one looks the simplest, but I actually encountered this pattern so often I abstracted it a bit more. I expect Checkly's feature set to grow quite a bit so having a fairly generic way of dealing with this is very handy. Here's the gist:

  .header Pagerduty
      // Pagerduty integration settings


There are two things going on here:

First, we check if the current plan has the feature PAGERDUTY enabled. Instead of using a component specific property, we use a global mixin to expose a function called $planHasFeature() to all templated elements.

What does this function do? Nothing more than checking the central data store if the currentAccount.features array holds the feature we pass into the function. The code is below.

const hasFeature = {
  created () {
    this.$planHasFeature = function (feature) {
      return this.features.includes(feature)
  computed: {
    features () {
      return this.$store.getters.currentAccount.features

Second, if this plan does not have this feature, we render a generic feature-not-available component. This is just a nice button that takes you to our upgrade page. This component is already used in nine other components, so I guess the extra abstraction was worth it.

With these patterns you can cater for a ton of common SaaS thing like showing upgrade messages and counter for volume based features. Hope it helps!

banner image: Kawase Hasui (1883-1957), "Seasonal flowers in Japan",  source


Sign up for more SaaS stories. New content every Monday! 🍍

Show Comments