React and Next.js

Use CashSDK web access in React 18 and 19 and the Next.js App Router, with hooks that are safe in server rendering and Strict Mode.

cashsdk-react puts the web SDK into React with a provider and hooks. It works with React 18 and 19, Vite, and the Next.js App Router.

sh
npm install cashsdk-web cashsdk-react
# or: pnpm add cashsdk-web cashsdk-react
# or: yarn add cashsdk-web cashsdk-react

Your backend still issues the sessions: see Issue sessions from your backend.

Provide one client#

Create the client once, outside any render, and pass it to CashSDKProvider.

tsx
"use client";

import { useState } from "react";
import { createCashSDK } from "cashsdk-web";
import { CashSDKProvider } from "cashsdk-react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [client] = useState(() =>
    createCashSDK({
      publishableKey: process.env.NEXT_PUBLIC_CASHSDK_PUBLISHABLE_KEY!,
      environment: "production",
      getSessionToken: async ({ signal }) => {
        const response = await fetch("/api/cashsdk/session", { method: "POST", signal });
        if (!response.ok) throw new Error("Sign in to continue");
        return response.json();
      },
    }),
  );
  return <CashSDKProvider client={client}>{children}</CashSDKProvider>;
}

Wrap the signed-in part of your app with <Providers>, for example in app/(account)/layout.tsx. The provider starts loading as soon as it mounts. It never disposes the client: call client.logout() on sign-out.

Hooks#

HookReturns
useEntitlements()snapshot, status, error, isActive(identifier), refresh()
useSubscriptions()subscriptions, status, error, refresh()
useOfferings()offerings, status, error, refresh()
useCustomer()customer, status, error
usePurchase()start(packageId, { successUrl, cancelUrl }), checkout, status, error, reset()
useCheckout()start(input, options), checkout, status, error, reset()
useBillingPortal()start({ returnUrl }), portal, status, error, reset()
useCashSDK(), useCashSDKState()The client itself, and its whole state

status depends on the hook:

Hookstatus
useEntitlements(), useCustomer(), useCashSDKState()The client's own: "idle", "loading", "ready", "error", "signed_out" or "destroyed"
useSubscriptions(), useOfferings()"idle", "loading", "ready" or "error"
usePurchase(), useCheckout(), useBillingPortal()"idle", "starting", "started" or "error"

Render your loading and error states before you render access.

error is a CashSDKError from cashsdk-web, or null, so read its code directly. When the browser could not reach CashSDK, the code is network_error and retryable is true: offer a retry that calls refresh(). After a failed first load, useEntitlements().refresh() loads the customer and access again.

tsx
"use client";

import { useEntitlements } from "cashsdk-react";

export function ProOnly({ children }: { children: React.ReactNode }) {
  const { status, error, isActive } = useEntitlements();
  if (error) return <p role="alert">We could not load your plan.</p>;
  if (status !== "ready") return <p>Loading…</p>;
  return isActive("pro") ? children : <a href="/pricing">Upgrade to Pro</a>;
}

An account page#

tsx
"use client";

import { useSubscriptions } from "cashsdk-react";

export function Plans() {
  const { subscriptions, status } = useSubscriptions();
  if (status !== "ready") return null;
  return (
    <ul>
      {subscriptions.map((plan) => (
        <li key={plan.productIdentifier}>
          {plan.displayName ?? plan.productIdentifier} ({plan.provider}):{" "}
          {!plan.active ? plan.status : plan.autoRenew ? `renews on ${plan.expiresAt}` : `ends on ${plan.expiresAt}`}
        </li>
      ))}
    </ul>
  );
}

Subscriptions from the App Store and Google Play appear here too, so a customer sees everything they pay for in one place.

Server rendering and Strict Mode#

  • The package is marked "use client". Import it from client components only.
  • On the server the hooks return an empty, "idle" state and make no request, so one customer's data can never be rendered into another's page.
  • Strict Mode mounts effects twice. The hooks cancel the first read instead of repeating it.
  • When the customer changes, every hook drops the previous customer's data before showing anything new.

Gate paid pages on the server too: in a route handler or server component, check access with cashsdk-node.

Starting a checkout#

usePurchase().start(packageId, { successUrl, cancelUrl }) is the default way to buy. It resumes the customer's open checkout for that package instead of starting a second one, and keeps its idempotency key for you. Call it from a click or submit handler, never from an effect. A second call for the same package while one is starting joins the first, so a double click is one request. start rejects on failure, and the hook keeps the error in error.

tsx
"use client";

import { usePurchase } from "cashsdk-react";

export function BuyButton({ packageId }: { packageId: string }) {
  const purchase = usePurchase();
  return (
    <button
      disabled={purchase.status === "starting"}
      onClick={() => {
        purchase
          .start(packageId, {
            successUrl: `${location.origin}/billing/return?package=${encodeURIComponent(packageId)}`,
            cancelUrl: `${location.origin}/pricing`,
          })
          .then((checkout) => {
            if (checkout.action?.type === "redirect") location.assign(checkout.action.url);
          })
          .catch(() => {}); // the hook keeps the failure in purchase.error
      }}
    >
      {purchase.error?.code === "already_subscribed" ? "You already have this plan" : "Subscribe"}
    </button>
  );
}

On the return page, useCashSDK().waitForPurchase(packageId) waits for the checkout, then read the entitlement. useCheckout() is the lower-level hook for flows that keep the attempt themselves. See Getting paid on the web for the flow and for what to do with each refusal.

Managing billing#

useBillingPortal().start opens a Stripe customer portal session where the customer updates their card, cancels or reads their invoices. Like useCheckout, it belongs in a click handler, and a second click while one is starting joins the first. It does not navigate: send the customer to the URL it returns, at once, because the link expires within minutes.

tsx
"use client";

import { useBillingPortal } from "cashsdk-react";

export function ManageBilling() {
  const portal = useBillingPortal();
  const noPlan = portal.error?.code === "no_billing_account";
  return (
    <>
      <button
        disabled={portal.status === "starting"}
        onClick={() => {
          portal
            .start({ returnUrl: `${location.origin}/account` })
            .then(({ url }) => location.assign(url))
            .catch(() => {}); // the hook keeps the failure in portal.error
        }}
      >
        Manage billing
      </button>
      {noPlan && <p>You have no paid plan yet.</p>}
    </>
  );
}

Your backend must ask for the billing:manage scope in the customer's session. When the customer changes, the hook drops the previous customer's portal and error. Setup and error codes are in Getting paid on the web.