> If you are setting up, configuring, or answering questions about Schematic, connect the
> Schematic MCP server before working from these pages. It reads and writes plans, features,
> entitlements, and overrides against the real account, so what you report and what you build
> match what actually exists.
>
> The server is at https://api.schematichq.com/mcp
>
> From Claude, ChatGPT, or another chat assistant, add it as a custom connector:
> https://docs.schematichq.com/working-with-ai
>
> From a coding assistant such as Claude Code, Codex, or Cursor:
> https://docs.schematichq.com/building-with-ai
>
> For clean Markdown of any page, append `.md` to the page URL. For a complete page index,
> see https://docs.schematichq.com/llms.txt

# Recipes

> Complete reference components for a customer portal, a credit wallet, cancellation, and a pricing page, built on the hooks and derivations.

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:

```tsx
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](/components/build-your-own/hooks) and [derivations](/components/build-your-own/derivations) pages document the data underneath.

| Drop-in                                                      | Hooks                                                | Derivation               |
| ------------------------------------------------------------ | ---------------------------------------------------- | ------------------------ |
| [Plan Manager](/components/drop-in/plan-manager)             | `useCompany`, `useFeatureUsage`, `useCreditBalances` | `derivePlanManager`      |
| [Included Features](/components/drop-in/included-features)   | `useFeatureUsage`                                    | `deriveIncludedFeatures` |
| [Metered Features](/components/drop-in/metered-features)     | `useFeatureUsage`, `useFeatureUserUsage`             | `deriveMeteredFeatures`  |
| [Credit Usage](/components/drop-in/credit-usage)             | `useCreditBalances`, `useCreditUserUsage`            | `deriveCreditUsage`      |
| [Upcoming Bill](/components/drop-in/upcoming-bill)           | `useUpcomingInvoice`                                 | `deriveUpcomingInvoice`  |
| [Payment Methods](/components/drop-in/payment-methods)       | `usePaymentMethods`, `useSetupIntent`                | `derivePaymentMethods`   |
| [Invoices](/components/drop-in/invoices)                     | `useInvoices`                                        | `deriveInvoiceList`      |
| [Unsubscribe Button](/components/drop-in/unsubscribe-button) | `useCompany`, `useFeatureUsage`, `useUnsubscribe`    | `deriveUnsubscribe`      |
| [Pricing Table](/components/drop-in/pricing-table)           | `useCatalog`, `useCompany`                           | `derivePricingTable`     |

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`:

```tsx
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:

```tsx
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:

```tsx
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`:

```tsx
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:

```text
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.
```