All recipes

Recipe

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. Web and native call usePlanPicker from better-supabase/blocks/billing/react.

The billing blockWebReact Native

1.What you build

No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.

2.Add the components

Web: shadcn

terminal
npx shadcn add button card

Native: React Native

The snippet uses ActivityIndicator, Pressable, Text, View from react-native, so it needs no styling library. For shadcn-style native components, React Native Reusables serves them from a registry the shadcn CLI reads.

3.Install the glue

Install the block with npx better-supabase sql add billing. The glue items are copied into your app, so edit them as you need.

Web

None: the hook calls the block from the client.

Native

None: the hook calls the block from the client.

4.Write the component

Web: React and shadcn

View in the example
plan-picker.tsx
"use client";

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

import { DbException } from "better-supabase";
import { usePlanPicker } from "better-supabase/blocks/billing/react";
import { useExtracted, useFormatter } 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 format = useFormatter();
  const errorMessage = useErrorMessage();
  const { plans, current, pending, choose } = usePlanPicker({
    load: loadCatalog,
    initial,
    checkout: (plan) => startCheckout({ plan }),
    open: (url) => {
      window.location.assign(url);
    },
  });
  const price = (cents: number) =>
    cents === 0
      ? t("Free")
      : t("{price} per month", {
          price: format.number(cents / 100, {
            style: "currency",
            currency: "USD",
          }),
        });
  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.priceCents)}</CardDescription>
            </CardHeader>
            <CardContent>
              <ul className="text-muted-foreground space-y-1 text-sm">
                {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>
  );
}

Native: React Native

View in the example
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>
  );
}