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.
npm install cashsdk-web cashsdk-react
# or: pnpm add cashsdk-web cashsdk-react
# or: yarn add cashsdk-web cashsdk-reactYour 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.
"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#
| Hook | Returns |
|---|---|
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:
| Hook | status |
|---|---|
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.
"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#
"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.
"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.
"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.