# Plan picker

> The plans with their prices and a checkout button each. Checkout starts in a server action or API route that holds the Stripe key, and the client opens the URL it returns.

Source: https://bettersupabase.com/docs/registry/ui-recipes/plan-picker

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `usePlanPicker` from `better-supabase/blocks/billing/react`; the [billing block](https://bettersupabase.com/blocks/billing) must be installed (`npx better-supabase add billing`). Its page on bettersupabase.com is [/recipes/plan-picker](https://bettersupabase.com/recipes/plan-picker).

Web, with shadcn components:

```bash
npx shadcn add button card
```

```tsx title="apps/examples/nextjs/src/features/billing/components/plan-picker.tsx"
"use client";

import type { PlanCatalog } from "better-supabase/blocks/billing/react";
import type { Money } from "better-supabase/commerce";

import { DbException } from "better-supabase";
import { usePlanPicker } from "better-supabase/blocks/billing/react";
import { formatMoney } from "better-supabase/commerce";
import { useExtracted, useLocale } from "next-intl";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { useErrorMessage } from "@/lib/use-error-message";

import type { PlanOption } from "../billing-queries";

import { loadPlanCatalog, startCheckout } from "../billing-actions";

async function loadCatalog(): Promise<PlanCatalog<PlanOption>> {
  const result = await loadPlanCatalog({});

  if (!result.ok) {
    throw new DbException(result.error);
  }

  return result.data;
}

/**
 * The plans with a checkout button each. The server renders the catalog;
 * `choose` runs the `startCheckout` server action, which talks to Stripe, and
 * sends the browser to the Checkout URL it returns.
 */
export function PlanPicker({
  initial,
  canManage,
}: {
  initial: PlanCatalog<PlanOption>;
  canManage: boolean;
}) {
  const t = useExtracted("billing");
  const locale = useLocale();
  const errorMessage = useErrorMessage();

  const { plans, current, pending, choose } = usePlanPicker({
    load: loadCatalog,
    initial,
    checkout: (plan) => startCheckout({ plan }),
    open: (url) => {
      window.location.assign(url);
    },
  });

  const price = (value: Money) =>
    value.amount === 0n
      ? t("Free")
      : t("{price} per month", { price: formatMoney(value, locale) });

  const featureLabel = ({ key, value }: PlanOption["features"][number]) => {
    switch (key) {
      case "seats": {
        return t("{seats} seats", { seats: String(value) });
      }

      case "exports": {
        return t("CSV exports");
      }

      case "audit": {
        return t("Audit log");
      }

      case "sso": {
        return t("Single sign-on");
      }

      default: {
        return key;
      }
    }
  };

  return (
    <div className="grid gap-4 md:grid-cols-3">
      {(plans ?? initial.plans).map((plan) => {
        const active = plan.key === current;

        return (
          <Card key={plan.key} size="sm" data-testid="plan">
            <CardHeader>
              <CardTitle>{plan.name}</CardTitle>
              <CardDescription>{price(plan.price)}</CardDescription>
            </CardHeader>
            <CardContent>
              <ul className="space-y-1 text-sm text-muted-foreground">
                {plan.features.map((feature) => (
                  <li key={feature.key}>{featureLabel(feature)}</li>
                ))}
              </ul>
            </CardContent>
            <CardFooter>
              <Button
                className="w-full"
                variant={active ? "secondary" : "default"}
                data-testid={active ? "active-plan" : undefined}
                disabled={active || !canManage || pending !== undefined}
                onClick={() => {
                  void choose(plan.key).then((started) => {
                    if (!started.ok) {
                      toast.error(errorMessage(started.error));
                    }
                  });
                }}
              >
                {active
                  ? t("Current plan")
                  : pending === plan.key
                    ? t("Opening checkout")
                    : t("Switch")}
              </Button>
            </CardFooter>
          </Card>
        );
      })}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `Pressable`, `Text`, `View` from `react-native`:

```tsx title="apps/examples/expo-powersync/src/components/plan-picker.tsx"
import { usePlanPicker } from "better-supabase/blocks/billing/react";
import { tenantOf, useAuth } from "better-supabase/react";
import * as WebBrowser from "expo-web-browser";
import { ActivityIndicator, Pressable, Text, View } from "react-native";

import { loadPlanCatalog, startCheckout } from "../lib/billing";

const price = (cents: number) =>
  cents === 0 ? "Free" : `$${(cents / 100).toFixed(2)} per month`;

/**
 * The plans with a checkout button each. `choose` posts to the app's own API
 * route, which talks to Stripe, and opens the Checkout URL it returns in the
 * system browser.
 */
export function PlanPicker() {
  const organizationId = tenantOf(useAuth());

  const { plans, current, pending, error, choose } = usePlanPicker({
    load: () =>
      organizationId
        ? loadPlanCatalog(organizationId)
        : Promise.resolve({ plans: [], current: undefined }),
    checkout: startCheckout,
    open: async (url) => {
      await WebBrowser.openBrowserAsync(url);
    },
  });

  if (plans === undefined) {
    return <ActivityIndicator />;
  }

  return (
    <View style={{ gap: 8 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>Plans</Text>
      {plans.map((plan) => {
        const active = plan.key === current;

        return (
          <Pressable
            key={plan.key}
            accessibilityRole="button"
            accessibilityState={{
              selected: active,
              busy: pending === plan.key,
            }}
            disabled={active || pending !== undefined}
            onPress={() => void choose(plan.key)}
            style={{
              flexDirection: "row",
              gap: 8,
              padding: 12,
              borderWidth: 1,
              borderRadius: 12,
              borderColor: active ? "#111" : "#e5e5e5",
            }}
          >
            <Text style={{ flex: 1, fontWeight: "600" }}>{plan.name}</Text>
            <Text style={{ color: "#737373" }}>{price(plan.priceCents)}</Text>
            <Text>
              {active ? "Current" : pending === plan.key ? "Opening" : "Switch"}
            </Text>
          </Pressable>
        );
      })}
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
    </View>
  );
}
```