Choose & switch plans
Section titled “Choose & switch plans”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.
defaultIntervalpicks 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 passonSelect, 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:
| Prop | Type | Default | Description |
|---|---|---|---|
successRedirect | string | '/subscription' | In-app route to land on after successful payment |
cancelRedirect | string | '/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 |
emptyState | ReactNode | (none) | Override the “no plans” message |
loadingState | ReactNode | (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:
| Parameter | Type | What it’s for |
|---|---|---|
plan | Plan | The plan to render: key, name, description, trial, trialDays, etc. |
prices | PriceOfferSdk[] | 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 |
isCurrent | boolean | true when this is the workspace’s current plan; use it to highlight the card and disable its buttons |
onPick | (price: PriceOfferSdk) => void | The 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) → callsselectFreePlan, refreshes billing, goes tosuccessRedirect(or callsonSelect)- paid +
paymentsEnabled→ asks “Change plan?” first, then callschangePlanand refreshes billing - paid + no payment method yet → calls
startCheckout, launches Stripe Checkout
Tip: Keep your render prop purely presentational. Don’t call
selectFreePlan,changePlan, orstartCheckoutyourself:onPickalready routes to the right one, and callingonSelecton the<PlanSelector>is how you react after a free selection or plan change completes.
Data attributes for CSS styling:
| Attribute | Values | When 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 |