Plan comparison grid
A plan-comparison matrix from PLANS with the current plan highlighted and checkout/portal CTAs, wired to the billing router. Degrades to the plain grid for non-owners.
A side-by-side plan-comparison matrix built straight off PLANS — the single
pricing source of truth — with a row per entitlement (members, projects, storage,
capability flags) so every plan column shares row order. Each column carries the
plan's headline price and a call to action.
billing.getSubscription marks the active org's current plan (the highlighted
column) and drives the CTAs: the current paid plan opens the Stripe Customer
Portal, any other paid plan starts Checkout. That query is owner-only, so a
non-owner's query fails with FORBIDDEN; rather than crash or leak the error, the
block degrades to the plain grid — no highlight, no CTAs.
Requires: billing
Preview
Free Free | ProCurrent $29/mo | Scale $15/seat/mo + usage | |
|---|---|---|---|
| Members | 2 members | 10 members | Unlimited |
| Projects | 1 projects | 10 projects | Unlimited |
| Storage | 100 MB | 5 GB | Unlimited |
| API access | — | ✓ | ✓ |
| Custom domain | — | ✓ | ✓ |
| Priority support | — | — | ✓ |
"use client";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";import { FREE_PLAN_KEY, PLANS } from "@/config/plans";import { formatPlanPrice } from "@/features/billing/lib/format";import { trpc } from "@/lib/trpc";import { planComparisonRows, type PlanFeatureCell } from "./plan-features";/** Render one matrix cell: a value string, or a ✓/— capability flag. */function CellValue({ cell }: { cell: PlanFeatureCell }) { if (cell.kind === "value") return <>{cell.text}</>; return ( <span aria-label={cell.on ? "Included" : "Not included"}> {cell.on ? "✓" : "—"} </span> );}/** * A plan-comparison matrix wired to the `billing` router. Columns come from * `PLANS` — the single pricing source of truth (D-004), the `upgrade-card` * precedent — with each plan's headline price (billing's `formatPlanPrice`) and * an entitlement grid (members, projects, storage, capability flags) shared row * order via the pure `planComparisonRows` transform. * * `billing.getSubscription` marks the active org's current plan (highlighted * column) and drives the CTAs: a paid current plan opens the Customer Portal * (`createPortalSession`), any other paid plan starts Checkout * (`createCheckoutSession`). That query is **owner-only** server-side, so a * non-owner viewer's query returns `FORBIDDEN`; per D-096 the block degrades to * the plain grid — no highlight, no CTAs — rather than crash or leak the error. */export function PlanComparisonGrid({ className }: { className?: string }) { const subscription = trpc.billing.getSubscription.useQuery(undefined, { retry: false, }); const checkout = trpc.billing.createCheckoutSession.useMutation({ onSuccess: ({ url }) => { window.location.href = url; }, }); const portal = trpc.billing.createPortalSession.useMutation({ onSuccess: ({ url }) => { window.location.href = url; }, }); // getSubscription is owner-only: a FORBIDDEN means the viewer isn't the owner, // so we render the plain grid with no highlight and no owner-only CTAs (D-096). const forbidden = subscription.error?.data?.code === "FORBIDDEN"; const showControls = !forbidden; const currentPlanKey = forbidden ? null : subscription.data ? subscription.data.planKey : subscription.isPending ? null : FREE_PLAN_KEY; const rows = planComparisonRows(PLANS); const busy = checkout.isPending || portal.isPending || subscription.isPending; function cta(planKey: string, planName: string, isPaid: boolean) { if (!showControls) return null; const isCurrent = currentPlanKey !== null && planKey === currentPlanKey; if (isCurrent) { return isPaid ? ( <Button variant="secondary" size="sm" disabled={busy} onClick={() => portal.mutate({})} > {portal.isPending ? "Redirecting…" : "Manage billing"} </Button> ) : ( <Button variant="secondary" size="sm" disabled> Current plan </Button> ); } // A non-current free plan is a downgrade target reached through the portal on // the current paid plan — no Checkout to offer, so it carries no CTA. if (!isPaid) return null; return ( <Button size="sm" disabled={busy} onClick={() => checkout.mutate({ planKey })} > {checkout.isPending ? "Redirecting…" : `Choose ${planName}`} </Button> ); } const isHighlighted = (planKey: string) => showControls && currentPlanKey !== null && planKey === currentPlanKey; return ( <div className={cn("w-full overflow-x-auto", className)}> <table className="w-full border-collapse text-sm"> <caption className="sr-only">Plan comparison</caption> <thead> <tr> <th scope="col" className="w-40" /> {PLANS.map((plan) => ( <th key={plan.key} scope="col" className={cn( "border-b p-3 text-left align-bottom", isHighlighted(plan.key) && "bg-muted", )} > <div className="flex flex-col gap-1"> <div className="flex items-center gap-2"> <span className="text-base font-semibold tracking-tight"> {plan.name} </span> {isHighlighted(plan.key) && ( <span className="rounded-full bg-foreground px-2 py-0.5 text-xs font-medium text-background"> Current </span> )} </div> <span className="text-lg font-semibold tracking-tight"> {formatPlanPrice(plan)} </span> </div> </th> ))} </tr> </thead> <tbody> {rows.map((row) => ( <tr key={row.label}> <th scope="row" className="border-b p-3 text-left font-medium text-muted-foreground" > {row.label} </th> {row.cells.map((cell, i) => ( <td key={PLANS[i].key} className={cn( "border-b p-3", isHighlighted(PLANS[i].key) && "bg-muted", )} > <CellValue cell={cell} /> </td> ))} </tr> ))} </tbody> <tfoot> <tr> <td /> {PLANS.map((plan) => ( <td key={plan.key} className={cn("p-3", isHighlighted(plan.key) && "bg-muted")} > {cta(plan.key, plan.name, plan.prices.length > 0)} </td> ))} </tr> </tfoot> </table> </div> );}Offline preview
The docs site has no backend, so the preview seeds a subscription on the Pro
plan to show the highlighted column and its "Manage billing" CTA. In an app the
current plan comes from the billing router; a non-owner instead sees the plain
grid, and the CTAs open Checkout / the Customer Portal.
Install
npx create-saas-starter add plan-comparison-gridRun it from your app's root. add only writes new files — wiring is up to you:
import { PlanComparisonGrid } from "@/components/blocks/plan-comparison-grid/plan-comparison-grid";Admin user lookup
A platform-admin back-office panel to search users as you type and impersonate one, wired to the admin router. Degrades to an explanatory notice for non-admins.
Usage trend chart
A usage-over-time line chart for one metric, wired to the usage router. Adds a near-cap upgrade link when billing is present.