DEV Community

Cover image for Building a SaaS Pricing page
CJ Avilla for Stripe

Posted on • Updated on

Building a SaaS Pricing page

Recall that when we talked about modeling your business, you learned that one way that many SaaS businesses make more money is by serving different types of users with different features or levels of service. An indie founder hacking on a side project doesn't have the same needs as a massive team at an enterprise company.

SaaS businesses who want to help both the indie founder and the enterprise must grapple with pricing their offerings. Ideally, prices reflect the value of the service. You've seen pricing pages where 2, 3, or 4 levels are displayed with some comparison table that outlines the differences between the tiers.

Maybe the levels are Startup, Growth, and Enterprise; or something like Individual and Family. These are typically modeled in a good-better-best type layout where users are shown the lowest price level that also includes the fewest features, support, and included assets alongside higher levels that often differentiate based on more features, faster or dedicated support, or more assets or content.

Example SaaS pricing page with three tiers

There are several ways to build a pricing table ranging in integration complexity and flexibility from the embeddable pricing table (fastest and highest leverage) to a fully custom pricing page. I’ll cover each so you can decide for yourself the best way to integrate.

Embeddable Pricing Table

The fastest way to create a pricing page is to use Stripe’s embeddable pricing table. Using the Stripe Dashboard, you can configure the UI component and embed it on your website to show pricing information to your customers and take them to checkout. Out of the box, the pricing table doesn’t require any custom code. Look at how simple the HTML is on this codepen:

We’ll drop in a little bit of custom code to specify a client_reference_id to ensure we can associate a new subscription back to an existing User in our database.

<script
  async
  src="https://js.stripe.com/v3/pricing-table.js">
</script>
<stripe-pricing-table
  pricing-table-id="<%= pricing_table_id %>"
  publishable-key="pk_test_vAZ3gh1LcuM7fW4rKNvqafgB00DR9RKOjN"
  client-reference-id="<%= current_user.id %>"
></stripe-pricing-table>
Enter fullscreen mode Exit fullscreen mode

You can customize the table with product images, product features and more — all through the Stripe Dashboard and without needing to deploy any new code. The embeddable pricing table is a brand-new feature and we’d love to hear how we can improve this so that it works great for your business.

The pricing table also enables you to price test by updating products and prices associated with a given pricing table. In a few moments, we’ll talk about why price testing is useful for your business.


Payment Links

Another low effort way to spin up a pricing page is to create Payment Links for each plan level and use those in your custom pricing page. Payment Links are a great option if you don’t have a traditional pricing page but instead are sharing links for users to sign up through social media, SMS, or email.

We can even embed a mini pricing table here with Payment Links:

Subscribe to Individual for $5/month
Subscribe to Family for $15/month

Payment Links also support passing a client_reference_id as a query param, so we can use this on our site, too. Here’s an example: https://buy.stripe.com/test_7sI8zHfcU9uYaC45lm?client_reference_id=888


Stripe Checkout or PaymentElement

If you’d rather build your own fully custom pricing page that works well with Stripe Checkout or a custom payment flow with PaymentElement, this section is for you. The pricing page for both will be the same and in both cases we will pass the ID of the Price the customer wants to subscribe to back to the server.

In the case of Checkout, we’ll create a new Checkout Session and redirect to the Stripe hosted page. In the case of PaymentElement, we’ll create a new Subscription and redirect to our custom payment flow.

Using Stripe Checkout requires less integration effort than Payment Element. PaymentElement enables you to build a highly customized payment flow and keep customers on your site, but is more development work to implement.

Remember from the earlier article about modeling your business with Products and Prices that each of these levels is represented in Stripe as a Product with one or many Prices? Let's take a quick peek into the future. When we eventually create a Subscription for the Customer, we'll need to pass the ID of a Price object.

Screenshot of Stripe API ref for creating subscriptions showing that a price ID is required on create

Knowing that, what information do we need to render a fully functional pricing page?
For each product we want to display:

  • Product Name
  • Features
  • Price
  • Recurring interval
  • Currency
  • Subscribe button (with Price ID)
  • Image?

Next question... where is this data coming from and what are the tradeoffs of storing it in our database versus fetching from Stripe with an API call?

Option A: Store all product and price data in the database

You could create Product and Price tables in the database to mirror the data in Stripe and then query the database for the display information. One drawback to this solution is that the Product catalog can quickly become out of date. If you make a change to a Product in the Stripe Dashboard, that wont flow into your system without wiring up a webhook handler for product.updated. Similarly, if you build a UI for managing your Product and Prices in your database, you'd need a way to sync those changes back to Stripe (in some cases updating in other cases creating new Prices).

Pros

  • Fast load times on the pricing page

Cons

  • Hard to manage state
  • Hard to update prices
  • Two potential sources of truth

Option B: Fetch from the Stripe API

Another option is to fetch Product and Price data from the Stripe API on your server when rendering the pricing page. One downside to this approach is adding more latency to a page that should be quick. The Product object also lacks a clean way to display a list of features.

Pros

  • Easy to change prices
  • Single source of truth
  • No tricky state management

Cons

  • Slower load times on the pricing page

Again, the best solution depends on your business. Are you hyper sensitive to latency on the pricing page? Have you already run several pricing experiments and are confident prices wont change?

I prefer to fetch the Products and Prices directly from the Stripe API because I believe the latency penalty is worth it for the added flexibility and simplicity. But that's not all, there's another lesser known benefit to this approach: price testing.

Implementation

In this example, we're taking advantage of a few more advanced features of the Price API. Here’s a bit of code in a Rails controller where we fetch a list of prices and their associated products.

class PricesController < ApplicationController
  def index
    @prices = Stripe::Price.list(
      active: true,
      recurring: { interval: params.fetch(:interval, month),},
      currency: 'usd',
      expand: ['data.product'],
      lookup_keys: ['startup', 'freelancer', 'enterprise'],
    ).data.sort_by { |p| p.unit_amount }
    @prices.each do |price|
      price.features = JSON.parse(price.product.metadata.features)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Let’s simplify and review each argument to the Prices list endpoint.

  • active: true - only return active prices. You may have archived prices, but some legacy users of your application might still have subscriptions that depend on these older prices. To filter those out, we pass the active flag.
  • type: 'recurring' - ensures we only retrieve recurring prices, not one-time prices.
  • currency: 'usd' - an improvement here would be to allow the customer to specify currency, or derive currency based on the customer's geo.
  • expand: ['data.product'] - expand tells Stripe to return the Prices' related Product object data instead of just the ID of the Product, cutting down on the need for N+1 API calls.
  • lookup_keys: ['startup', 'freelancer', 'enterprise'] - this is where things get interesting. Lookup keys enable us to tie a Price object to a specific key. Say we create a price of $5/month for our Starter plan. Then later we increase the price of Starter to $7/month. There isn't a way to edit the unit_amount value on the Price object, so instead we must create a new Price. With a lookup_key we can transfer the lookup_key of starter from the $5/month price to the new $7/month price.

After the API call returns with a list of prices, we hack a list of product features from the metadata stored on the Product so that we can display a list of included features. You may have noticed Products now support a list of features natively; however these are not exposed in the API, yet.

Here's what the HTML might look like:

<% if @prices.empty? %>
  <p>No products yet.</a></p>
<% end %>
<% @prices.each do |price| %>
  <div>
    <div>
      <img src="<%= price.product.images.try(:first) %>" alt="<%= price.product.description %>">
    </div>
    <div>
      <h3><%= price.product.name %></h3>
      <p><%= price.product.description %></p>
    </div>
    <div>
      <div aria-hidden="true" class="absolute inset-x-0 bottom-0 h-36 bg-gradient-to-t from-black opacity-50"></div>
      <p><%= number_to_currency(price.unit_amount/100, precision: 0) %></p>
    </div>
    </div>
    <form action="/checkout" method="post" data-remote="true">
      <input type="hidden" name="price" value="<%= price.id %>">
      <button type="submit">
        Subscribe to <%= price.product.name %>
      </button>
    </form>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

(Bonus!) Price testing

Followers of Patrick McKenzie (patio11) know a common refrain in his business advice: charge more. Julian Lehr outlines 9 tricks for experimenting with pricing. At the core of this advice is a truth: choosing the right price to charge is hard. The future success of your business could depend on you charging the right amounts. Charge too little and you wont have enough capital to sustain the business, charge too much and you’ll price yourself out of the market.

Let’s define Price testing or price experimentation as the practice of periodically changing price amounts and tiers to test hypotheses about specific customer segment’s willingness to pay. To do this right, it’s critical that you have good telemetry in place so that you know how a change impacted conversion.

Price testing with the embeddable pricing table is a breeze. You can edit the products and prices shown for a given pricing table directly from the dashboard.

Testing prices for a custom pricing page is a little more involved, but by using a lesser known feature, lookup_key you can test prices without deploying any new code. To test prices with the pricing page above, we want to ensure that we're also including the lookup_key on Price create for our new prices.

price = Stripe::Price.create(
  currency: 'usd',
  product: 'prod_abc123',
  unit_amount: 500,
  recurring: {
    interval: 'month',
  },
  lookup_key: 'starter',
)
Enter fullscreen mode Exit fullscreen mode

When we want to change our price to $7/month, we can use the transfer_lookup_key argument:

price = Stripe::Price.create(
  currency: 'usd',
  product: 'prod_abc123',
  unit_amount: 700,
  recurring: {
    interval: 'month',
  },
  lookup_key: 'starter',
  # important!
  transfer_lookup_key: 'starter',
)
Enter fullscreen mode Exit fullscreen mode

Now our pricing page is updated and we don't need to deploy any new pricing page code!


I recommend starting with Pricing Table or Payment Links. If you need more features and control, choose Stripe Checkout. If you are ready to invest in your custom payment flow, choose PaymentElement.

About the author

CJ Avilla

CJ Avilla (@cjav_dev) is a Developer Advocate at Stripe, a Ruby on Rails developer, and a YouTuber. He loves learning and teaching new programming languages and web frameworks. When he’s not at his computer, he’s spending time with his family or on a bike ride 🚲.

Top comments (0)