Skip to navigation

Each recipe is a complete component built on the hooks and derivations, styled with nothing but class names you supply. Copy the one closest to what you need into your codebase and restyle it.

Customer portal

One page, one hook per resource, each section loading and retrying on its own. Section handles the pending and failed states once, so each card only renders data:

import type { ReactNode } from "react";
import {
useCompany,
useCreditBalances,
useFeatureUsage,
useInvoices,
usePaymentMethods,
useUpcomingInvoice,
type ResourceHandle,
} from "@schematichq/schematic-react";
import {
deriveInvoiceList,
deriveMeteredFeatures,
derivePaymentMethods,
derivePlanManager,
deriveUpcomingInvoice,
useTranslator,
} from "@schematichq/schematic-components/elements";
const locale = "en-US";
function Section<T>({ handle, children }: { handle: ResourceHandle<T>; children: (data: T) => ReactNode }) {
if (handle.data !== undefined) return <>{children(handle.data)}</>;
if (handle.error) {
return (
<div role="alert">
{handle.error.message} <button onClick={handle.refetch}>Retry</button>
</div>
);
}
return <div className="skeleton" />;
}
function Plan() {
const company = useCompany();
const featureUsage = useFeatureUsage();
const creditBalances = useCreditBalances();
return (
<Section handle={company}>
{(companyData) => (
<Section handle={featureUsage}>
{(usageData) => (
<Section handle={creditBalances}>
{(creditData) => {
const view = derivePlanManager(
{ company: companyData, featureUsage: usageData, creditBalances: creditData },
{ locale },
);
const price = view.plan?.price;
return (
<section>
<h2>{view.plan?.name ?? "No plan"}</h2>
{price?.kind === "amount" && <p>{price.amount}{price.period && ` / ${price.period}`}</p>}
{view.notice?.kind === "canceled" && <p>Access ends {view.notice.date}</p>}
<ul>
{view.addOns.map((addOn) => <li key={addOn.id}>{addOn.name}</li>)}
</ul>
</section>
);
}}
</Section>
)}
</Section>
)}
</Section>
);
}
function Usage() {
const featureUsage = useFeatureUsage();
return (
<Section handle={featureUsage}>
{(features) => (
<section>
{deriveMeteredFeatures(features, { locale }).map((row) => (
<div key={row.featureId}>
<h3>{row.name}</h3>
{row.meter && (
<>
<progress max={row.meter.total} value={row.meter.value} data-tone={row.meter.tone} />
<p>{row.meter.valueText} of {row.meter.totalText}</p>
</>
)}
{row.resetsAt && <p>Resets {row.resetsAt}</p>}
</div>
))}
</section>
)}
</Section>
);
}
function NextBill() {
const upcoming = useUpcomingInvoice();
return (
<Section handle={upcoming}>
{(invoice) => {
if (invoice === null) return <p>Nothing to bill</p>;
const bill = deriveUpcomingInvoice(invoice, { locale });
return (
<section>
<h2>Next bill{bill.dueAt && ` due ${bill.dueAt.text}`}</h2>
<p>{bill.amountDueText}</p>
{bill.discounts.map((discount, i) => (
<p key={i}>{discount.code ?? discount.couponName}: {discount.valueText} off</p>
))}
</section>
);
}}
</Section>
);
}
function PaymentMethod() {
const t = useTranslator();
const paymentMethods = usePaymentMethods();
return (
<Section handle={paymentMethods}>
{(methods) => {
const { current, expiryWarning } = derivePaymentMethods(methods, { locale });
if (current === null) return <p>No payment method on file</p>;
const label = current.label.key !== undefined ? t(current.label.key) : current.label.text;
return (
<section>
<p>{label} {current.last4} {current.expiresShort}</p>
{expiryWarning !== "none" && <p role="alert">This card {expiryWarning === "expired" ? "has expired" : "expires soon"}</p>}
</section>
);
}}
</Section>
);
}
function Invoices() {
const invoices = useInvoices({ includePending: true });
return (
<Section handle={invoices}>
{(page) => {
const list = deriveInvoiceList(page, { locale });
return (
<section>
<ul>
{list.rows.map((row) => (
<li key={row.id}>
{row.url ? <a href={row.url}>{row.dateText}</a> : row.dateText}{" "}
{row.isCredit ? `(${row.amountText})` : row.amountText}
</li>
))}
</ul>
{list.hasMore && (
<button disabled={invoices.isPending} onClick={invoices.loadMore}>Load more</button>
)}
</section>
);
}}
</Section>
);
}
export default function BillingPage() {
return (
<div className="space-y-4">
<Plan />
<Usage />
<NextBill />
<PaymentMethod />
<Invoices />
</div>
);
}

useTranslator resolves a payment method’s label key to the provider’s strings or translate, falling back to the English copy, so the label follows the same overrides as the drop-ins.

What each drop-in reads

Each drop-in is the fullest version of a section like the ones above. Its page documents every prop, and the hooks and derivations pages document the data underneath.

Drop-inHooksDerivation
Plan ManageruseCompany, useFeatureUsage, useCreditBalancesderivePlanManager
Included FeaturesuseFeatureUsagederiveIncludedFeatures
Metered FeaturesuseFeatureUsage, useFeatureUserUsagederiveMeteredFeatures
Credit UsageuseCreditBalances, useCreditUserUsagederiveCreditUsage
Upcoming BilluseUpcomingInvoicederiveUpcomingInvoice
Payment MethodsusePaymentMethods, useSetupIntentderivePaymentMethods
InvoicesuseInvoicesderiveInvoiceList
Unsubscribe ButtonuseCompany, useFeatureUsage, useUnsubscribederiveUnsubscribe
Pricing TableuseCatalog, useCompanyderivePricingTable

The Components page in the Schematic dashboard has reference code for each drop-in in shadcn/ui, Tailwind, and plain CSS.

Credit wallet

Each credit with what is left, its grants, and the people spending it. Buy more opens Checkout on its credits step, so render it under a CheckoutLauncherProvider:

import {
deriveCreditUsage,
useCheckoutLauncher,
useCreditBalances,
useCreditUserUsage,
} from "@schematichq/schematic-components/elements";
export function Wallet() {
const { data, error, refetch } = useCreditBalances();
const { open } = useCheckoutLauncher();
if (data === undefined) {
return error ? <button onClick={refetch}>Retry</button> : <div className="skeleton" />;
}
return (
<>
{deriveCreditUsage(data, { locale: "en-US" }).map((credit) => (
<section key={credit.creditId}>
<h3>{credit.name}</h3>
<p>{credit.remaining.amount} {credit.remaining.units} remaining</p>
<ul>
{credit.ledger.map((grant) => (
<li key={grant.id}>
{grant.amount} {grant.item} ({grant.kind}), {grant.createdAt}
{grant.date && `, ${grant.date.kind} ${grant.date.text}`}
</li>
))}
</ul>
{credit.ledger.length > 0 && <TopSpenders creditId={credit.creditId} />}
{credit.purchasable && <button onClick={() => open({ steps: { initial: "credits" } })}>Buy more</button>}
</section>
))}
</>
);
}
function TopSpenders({ creditId }: { creditId: string }) {
const { data } = useCreditUserUsage(creditId);
if (data === undefined || data.users.length === 0) return null;
return (
<ol>
{data.users.map((user) => (
<li key={user.userId}>
{user.name ?? user.userId}: {user.used}
</li>
))}
</ol>
);
}

purchasable is true only when a bundle of the credit is on offer to the company’s plan, so the button shows where there is something to buy.

Cancel at period end

A cancel button with a confirmation that says when access ends and what the customer stops paying for:

import { useState } from "react";
import {
deriveUnsubscribe,
useCompany,
useFeatureUsage,
useUnsubscribe,
} from "@schematichq/schematic-components/elements";
export function CancelSubscription() {
const company = useCompany();
const featureUsage = useFeatureUsage();
const { unsubscribe, isMutating, mutationError } = useUnsubscribe();
const [confirming, setConfirming] = useState(false);
if (company.data === undefined || featureUsage.data === undefined) return null;
const view = deriveUnsubscribe(company.data, featureUsage.data, { locale: "en-US" });
if (!view.canUnsubscribe) return null;
if (!confirming) return <button onClick={() => setConfirming(true)}>Cancel subscription</button>;
return (
<div role="dialog" aria-label="Cancel subscription">
<p>You keep access until {view.accessEndsOn}.</p>
{view.plan && <p>{view.plan.name} {view.plan.price}</p>}
{view.addOns.map((addOn) => <p key={addOn.id}>{addOn.name} {addOn.price}</p>)}
<p>Total {view.total} per {view.period}</p>
<button disabled={isMutating} onClick={() => unsubscribe().then(() => setConfirming(false), () => {})}>
Confirm cancellation
</button>
<button onClick={() => setConfirming(false)}>Keep my plan</button>
{mutationError && <p role="alert">{mutationError.message}</p>}
</div>
);
}

Once the cancellation lands, useCompany reloads with a cancellation date, canUnsubscribe turns false, and the button disappears.

Pricing page

The same component serves a public page and a signed-in one. Signed out, useCatalog reads the public catalog with the publishable key and every card’s action is null; signed in, it reads the company’s view and each card’s action says what the company can do with it. Selection state lives in your component, and the derivation returns what the catalog offers:

import { useState } from "react";
import { useCatalog, useCompany } from "@schematichq/schematic-react";
import {
checkoutSelectionFor,
derivePricingTable,
type CheckoutSelection,
type PricingPeriod,
} from "@schematichq/schematic-components/elements";
export function Pricing({ onSelect }: { onSelect: (selection: CheckoutSelection) => void }) {
const catalog = useCatalog();
const company = useCompany();
const [period, setPeriod] = useState<PricingPeriod>();
const [currency, setCurrency] = useState<string>();
if (catalog.data === undefined) {
return catalog.error ? <button onClick={catalog.refetch}>Retry</button> : <div className="skeleton" />;
}
// The company's period decides which plan is current, so wait for it.
const companyMode = catalog.data.source === "company";
if (companyMode && company.data === undefined && company.error === undefined) {
return <div className="skeleton" />;
}
const table = derivePricingTable(catalog.data, {
locale: "en-US",
period,
currency,
company: companyMode ? company.data : undefined,
});
return (
<>
<select value={table.period ?? ""} onChange={(event) => setPeriod(event.target.value as PricingPeriod)}>
{table.periods.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select value={table.currency ?? ""} onChange={(event) => setCurrency(event.target.value)}>
{table.currencies.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<div className="grid md:grid-cols-3">
{table.plans.map((plan) => (
<article key={plan.id}>
<h3>{plan.name}</h3>
<p>{plan.price.kind === "amount" ? plan.price.amount : plan.price.kind}</p>
{plan.action?.kind === "current" ? (
<p>Current plan</p>
) : plan.action?.kind === "downgradeBlocked" ? (
<a href={plan.action.url ?? undefined}>{plan.action.text ?? "Talk to support"}</a>
) : (
<button
disabled={plan.action?.kind === "overLimit" || plan.action?.kind === "unavailable"}
onClick={() => onSelect(checkoutSelectionFor(table, plan, "plan"))}
>
Choose plan
</button>
)}
</article>
))}
</div>
</>
);
}

Render from table.period and table.currency rather than your own state: when the catalog does not offer what you asked for, the derivation falls back and says which it chose. checkoutSelectionFor builds the cart for a card, with its plan, period, and currency. On a public page, onSelect sends the visitor to sign up. Signed in, mount CheckoutLauncherProvider above it and open Checkout from onSelect:

const { open } = useCheckoutLauncher();
<Pricing onSelect={(selection) => open({ selection })} />

Implement with AI

The Components page in the dashboard has a prompt for each drop-in. The shape of each:

Add an "Invoices" section to my app's billing page using Schematic.
- The app is wrapped in <SchematicProvider publishableKey session={{ company, user, token }}>.
- Fetch with useInvoices(query?) from @schematichq/schematic-react → { data: InvoicePage, error, isPending, refetch, loadMore }.
- Format rows with deriveInvoiceList(data, { locale }) from @schematichq/schematic-components/elements.
- Reference implementation: https://docs.schematichq.com/components/build-your-own/recipes (adapt to our design system).
- Handle isPending with skeletons and error with a retry button.