Guides
Build guide

An annual and monthly toggle in Next.js, and why it is not a boolean

How to add an annual and monthly toggle in Next.js: why the display interval is not the billing interval, what happens to plan allowances, and where the savings percentage should come from.

Dharmendra Jagodana8 min read

In short

A pricing toggle changes which prices are on screen, not how anyone is billed. Keep the display interval as its own type: the price follows the toggle, the allowance does not, because monthly allowances reset every month whatever the billing interval. Derive the savings badge from the two prices.

A pricing toggle looks like the smallest component on the page. Two buttons, one piece of state, swap a number.

Then someone on annual billing asks why their allowance did not go up, and you find out what the toggle was actually promising. The price column moved. The allowances underneath it moved too, because they were rendered from the same variable, and now the yearly card offers 100,000 users a month against a figure the metering resets every month regardless.

The toggle is a display control. It decides which prices are on screen. It does not decide how anyone is billed, and the moment those two ideas share a type, the page starts making claims nobody wrote.

What you are building

Two buttons above a set of plan cards. Click Yearly and every price on the page switches to the annual figure, a badge appears with the real saving, and the line under each price reads "that's $41 per month, billed annually". The allowances in the card body do not move at all.

Working looks like this: on our own pricing page, Launch reads $49 on the monthly column and $490 on the yearly one. The included figures below the price are identical in both. That is not a bug someone has not got round to. It is the whole point.

Before you start

A Next.js app with @buildbase/sdk installed and a plan group configured, so usePublicPlans has something to return. You do not need a subscription or a signed-in user: this is the public plans payload, which is the same data an anonymous visitor sees.

Step one: give the display interval its own type

Start with the type, because it is the decision the rest of the guide falls out of.

export type PricingDisplayInterval = 'monthly' | 'yearly';

That lives in pricing-presentation.ts in our shared package, and it is deliberately not the BillingInterval enum that the subscription types export. That one has three members, monthly, yearly and quarterly, because it describes the period Stripe charges on. This one has two, because it describes which column you are looking at.

They look like the same string union. They are not the same fact. One is a billing decision a customer makes at checkout; the other is a UI state that resets when they reload the page.

'use client';

import { useState } from 'react';
import { usePublicPlans } from '@buildbase/sdk/react';
import type { PricingDisplayInterval } from '@ord/shared';

export function PricingInner() {
  const { items, plans, loading, error } = usePublicPlans('main');
  const [interval, setInterval] = useState<PricingDisplayInterval>('monthly');

  if (loading) return <PricingSkeleton />;
  if (error || !plans?.length) return <FallbackPricing />;

  return <PlanGrid plans={plans} items={items} interval={interval} />;
}

You should see plans come back with a pricingVariants array on each one, and items as the catalog of everything a plan can include. Two payloads, because what a plan costs and what a plan contains are configured separately in the console.

Default to monthly. Annual-first is a real conversion tactic and I am not going to pretend it never works, but it means the first number a visitor sees is not the number they will be charged if they click through on the default path, and that gap is where refund emails come from.

Step two: read the price for the column on screen

Answer first: index the plan's base pricing by the display interval, and handle the null rather than rendering it.

function getPlanPrice(plan: any, interval: string): number | null {
  const variants = plan.pricingVariants || [];
  const variant =
    variants.find((v: any) => v.currency === 'usd') || variants[0];
  if (!variant?.basePricing) return null;
  return variant.basePricing[interval] ?? null;
}

basePricing carries monthly, yearly and quarterly, all optional. So the null is not defensive padding. A plan priced monthly and never given an annual figure returns null on the yearly column, and that is a real state in a catalog someone is still filling in.

What you render for that null is a product decision worth making on purpose. Our page falls through to "Custom", which is right for the Enterprise tier and wrong for a plan an admin simply has not finished pricing. Hiding the card would be worse. A plan that vanishes when you click Yearly reads as a bug to everyone except the person who knows why.

Then the line under the price:

function PriceNote({ interval, pricing, currency }: PriceNoteProps) {
  if (interval !== 'yearly') return null;

  return (
    <CardDescription className="mt-2">
      That&apos;s {formatPrice(pricing / 12, currency)} per month, billed
      annually
    </CardDescription>
  );
}

Watch the rounding here. formatPrice is set to zero fraction digits, so Launch at $490 a year divides to $40.83 and prints as $41. Multiply the printed figure back out and you get $492, which is not a price we charge. Nobody has ever complained about this and I would still not put the derived number next to the real one without knowing it is rounded, because "$41 x 12" is arithmetic a buyer does in their head on the way to a budget meeting.

Step three: leave the allowances alone

This is the step every pricing-toggle tutorial skips, and it is the one that produces a false claim.

The figure a plan includes of a metered item follows the toggle. The period label does not.

const tier = quota[interval] ?? quota.monthly ?? quota;
const included = tier?.included ?? quota.included;

return {
  amount: formatNum(included),
  unit: item.name,
  period: NAME_CARRIES_PERIOD.test(item.name) ? '' : 'per month',
  note: !allowOverage
    ? 'hard limit'
    : overageCents != null && overageCents > 0
      ? formatRate(overageCents, unitSize, currency)
      : undefined,
};

Both halves of that are load-bearing. The included figure is read per interval, because a catalog can price the same quota differently on an annual plan. The period stays "per month" whatever the column, because monthly allowances reset every month whatever the billing interval. Paying a year up front buys twelve monthly allowances in sequence, not one annual pool to spend how you like.

Key takeaway

The price follows the toggle. The allowance figure follows the toggle. The period label never does, because the reset is monthly on every interval.

Get this wrong in the obvious direction, by multiplying the included figure by twelve on the yearly column, and the page promises an annual pool. A customer who burns through it in March has been told something the metering will not honour, and they have it in a screenshot.

One more detail in that snippet, which cost us a rewrite. NAME_CARRIES_PERIOD checks whether the item's own name already says how often it resets. Plenty of catalogues name the item "Emails sent / mo" or "Monthly active users", and appending our period to either gives you "Monthly active users per month". The name wins, because it is the author's wording and it is what the comparison table prints as the row label.

Step four: derive the savings badge, do not type it

The badge on the Yearly button is where hardcoded numbers go to rot.

function annualSavingPercent(plan: any): number | null {
  const monthly = getPlanPrice(plan, 'monthly');
  const yearly = getPlanPrice(plan, 'yearly');
  if (!monthly || !yearly) return null;
  return Math.round(((monthly * 12 - yearly) / (monthly * 12)) * 100);
}

Our three plans are each priced at ten months for twelve. Launch is $49 a month or $490 a year, Grow is $99 or $990, Scale is $199 or $1,990. Two months free on all three, which is 16.67%, which is the 17% our page prints.

And our page prints it as a string literal. The prices come live from the database and the badge is typed into the JSX, so the day someone edits an annual price in the console the badge goes on claiming 17% and no build, test or type error says otherwise. It is right today. It is right by coincidence, and I would rather write that down than let this guide recommend something our own page has not got round to doing.

The derived version also lets you say the true thing when plans disagree. If Launch saves 17% and Scale saves 22%, "Save up to 22%" is honest and "Save 22%" is not.

The failure modes

Reusing the billing-interval type for the toggle. The one that starts all the others. Widen the display type to three members so it matches the enum, and the label map goes out of sync with it: ours holds monthly and yearly only, with INTERVAL_LABELS[interval] || INTERVAL_LABELS.monthly as the fallback. A quarterly column would render the quarterly price with "/mo" beside it, silently, because the fallback is doing what it was written to do.

Assuming two intervals because the toggle has two buttons. Our catalog prices quarterly as well, at roughly 10% off, and no column on the pricing page shows it. That is a choice rather than an oversight: a third button costs more attention than quarterly billing earns on a marketing page, and checkout is where a customer picks the period they are actually billed on. Just do not write code that believes yearly is the only alternative to monthly.

Putting the interval in the URL and forgetting the default. ?billing=yearly is a reasonable thing to support, because it makes the annual column linkable from an email. Read an unknown value straight into state and you get a column with no labels and no prices, since both lookups miss. Validate against the two allowed strings and fall back to monthly.

Treating the toggle as a commitment. It sets no state on the server. A visitor who clicks Yearly and then starts a trial is still on whatever interval the checkout passes, and the interval is an argument to the subscription call, not something the page remembered about them. Our own trial is 7 days and takes no card, so there is a stretch where the customer has picked a plan, seen an annual price, and has no billing interval at all yet.

Letting the card and the table format money differently. Once the price appears on a card, in a comparison row and in an overage note, three renderers can round three ways. Keep one formatPrice and one formatNum and import them everywhere, which is why ours live in the shared package rather than in the page.

I have not run this exact file end to end as one app. getPlanPrice and the allowance block are lifted from our own pricing surface rather than written for this post; PriceNote is the same render pulled out into its own component, because ours sits inline in a card. The behaviour described is what the page does today.

npm i @buildbase/sdk
billing
nextjs
pricing

Frequently Asked Questions

Should the yearly column show yearly allowances?

No. Allowances reset every month whatever the billing interval, so the yearly column shows a monthly allowance, labelled per month. Multiplying it by twelve promises an annual pool the metering does not give.

Is the display interval the same as the billing interval?

No, and keeping them as one type is the bug. The display interval is which column is on screen. The billing interval is the period the customer is actually charged on, and it is decided at checkout, not by a toggle on a marketing page.

How should the savings percentage on the annual tab be calculated?

From the two prices you already have: (monthly x 12 - yearly) / (monthly x 12). Hardcoding it means the badge keeps claiming a discount after someone edits a price in the catalog, and nothing in the build will tell you.

What if the plan catalog has more than two billing intervals?

Two buttons is still the right call for a marketing page, because a third column costs more attention than quarterly billing wins. Handle it by treating the toggle as a two-value display type rather than widening it, and offering the other intervals at checkout.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime