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.
1.What you build
No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.
2.Add the components
Web: shadcn
npx shadcn add button cardNative: 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"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 exampleimport { 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>
);
}