Skip to content

This is the self-service billing page most apps need: one place where a user picks their first plan, upgrades, downgrades, or switches billing interval. <PlanSelector> is the whole thing in one component. Unlike <BridgePaywall>, which forces a choice before the app loads, this is the always-available page a user visits when they choose to.

With <BridgeBillingRoutes> mounted at /subscription/* (see Add billing to your app), /subscription already is that page: the current plan, “Manage billing” and the picker. To customise the picker there, take the page over by element — <BridgeBillingRoutes pages={{ manage: <MySubscriptionPage /> }} /> — and render <PlanSelector> inside it.

<PlanSelector> loads the plans and the status of the current workspace (called a tenant in the API) automatically, renders plan cards, and handles free plan selection, Stripe Checkout, and plan changes.

// src/pages/SubscriptionPage.tsx
import { PlanSelector } from '@nebulr-group/bridge-react';

export default function SubscriptionPage() {
  return <PlanSelector successRedirect="/subscription/success" cancelRedirect="/subscription" />;
}

What it does for you:

  • Billing interval tabs. When the plans offer more than one paid interval, Monthly / Yearly (and Weekly / Daily) tabs appear. defaultInterval picks the initial tab (default 'year', falling back to the first interval offered). A free plan stays selectable under every tab.
  • Cheapest plan first, by each plan’s cheapest price, so the order doesn’t change when the user switches tabs.
  • What each plan includes: the plan’s features list (bridge plan feature add …), the same list the upgrade dialog names.
  • A confirm step before an instant switch. A workspace that already pays switches plans immediately (no checkout page), so the picker asks “Change plan?” first and shows a confirmation once done.
  • A free pick goes on to successRedirect, like a completed checkout does — unless you pass onSelect, in which case it calls it and stays put.
  • An empty plan list offers “Try again”, since it is more often a failed read than an app with no plans.

Props:

PropTypeDefaultDescription
successRedirectstring'/subscription'In-app route to land on after successful payment
cancelRedirectstring'/subscription'In-app route to land on if the user cancels checkout
defaultInterval'day' | 'week' | 'month' | 'year''year'The interval tab selected at first
onSelect({ plan, price }) => void(none)Called after a free plan is selected or a plan change completes. When given, the picker stays on the page instead of going to successRedirect
planDescription({ plan, isCurrent }) => ReactNode(none)Replaces the description paragraph of the default card
planFooter({ plan, isCurrent }) => ReactNode(none)Rendered at the bottom of the default card, after the price buttons
planCard({ plan, prices, isCurrent, interval, onPick }) => ReactNode(none)Replaces the whole default card
emptyStateReactNode(none)Override the “no plans” message
loadingStateReactNode(none)Override the loading spinner

All standard HTMLAttributes<HTMLDivElement> props (className, style, data-*, etc.) are forwarded to the root element.

Customising a card, smallest change first:

Change only the description, or add a line under the buttons, and keep the rest of the default card:

<PlanSelector
  planDescription={({ plan }) => <p className="my-plan-copy">{copyFor(plan.key)}</p>}
  planFooter={({ plan, isCurrent }) => (isCurrent ? <small>Your plan</small> : <small>Cancel any time</small>)}
/>

Custom plan card:

The default plan cards are intentionally plain. If you want them to match your product’s design, pass a planCard render prop and render the cards yourself. You get the plan data and a ready-made pick handler, and Bridge still owns the free-select / checkout / change branching. You only write markup; you never touch the SDK. Here’s an example:

import { PlanSelector } from '@nebulr-group/bridge-react';

export default function SubscriptionPage() {
  return (
    <PlanSelector
      successRedirect="/subscription/success"
      cancelRedirect="/subscription"
      planCard={({ plan, prices, isCurrent, onPick }) => (
        <div className={isCurrent ? 'plan-card current' : 'plan-card'}>
          <h2>{plan.name}</h2>
          {plan.trial && <span className="badge">Free {plan.trialDays}-day trial</span>}
          {prices.map((price) => (
            <button
              key={price.recurrenceInterval + price.currency}
              disabled={isCurrent}
              onClick={() => onPick(price)}
            >
              {price.amount === 0 ? 'Free' : `${price.amount} ${price.currency.toUpperCase()} / ${price.recurrenceInterval}`}
            </button>
          ))}
        </div>
      )}
    />
  );
}

The render prop is called once per plan and receives an object with five fields:

ParameterTypeWhat it’s for
planPlanThe plan to render: key, name, description, trial, trialDays, etc.
pricesPriceOfferSdk[]The plan’s price offers (amount, currency, recurrenceInterval); one button per price is the usual layout
interval'day' | 'week' | 'month' | 'year'The active interval tab, to show only that interval’s price
isCurrentbooleantrue when this is the workspace’s current plan; use it to highlight the card and disable its buttons
onPick(price: PriceOfferSdk) => voidThe pick handler; call it with the chosen price when the user clicks

All you have to wire is calling onPick(price); the component figures out whether that’s a free selection, a paid checkout, or a plan change. Under the hood, onPick(price) branches on the price and the workspace’s payment state:

  • price.amount === 0 (and no metered cost) → calls selectFreePlan, refreshes billing, goes to successRedirect (or calls onSelect)
  • paid + paymentsEnabled → asks “Change plan?” first, then calls changePlan and refreshes billing
  • paid + no payment method yet → calls startCheckout, launches Stripe Checkout

Tip: Keep your render prop purely presentational. Don’t call selectFreePlan, changePlan, or startCheckout yourself: onPick already routes to the right one, and calling onSelect on the <PlanSelector> is how you react after a free selection or plan change completes.

Data attributes for CSS styling:

AttributeValuesWhen set
data-bridge-plan-selector(no value)Always present on root
data-loading"true" / "false"Loading + in-flight pick state
data-state"idle" "select-plan" "active" "trial" "payment-failed" "setup-payments"Current status
data-bridge-plan-card(no value)On each plan card
data-current"true" / "false"Whether this card is the current plan
data-trial"true" / "false"Whether this plan has a trial