Skip to Content

Components

Declarative wrappers over the hooks. They cover the most common patterns — 80% of integrations skip useEffect boilerplate. For the rest, reach for the hooks directly.

<PaywallGate>

Declarative gate: loadingfallbackchildren. Powered by usePaywallAccess.

<PaywallGate loading={<Skeleton />} fallback={({ open }) => <button onClick={open}>Upgrade</button>} > <PremiumFeature /> </PaywallGate>

Props

PropTypeWhat it does
childrenReactNodePremium content. Rendered only when access === 'granted'.
fallbackReactNode \| ((args: BlockedRenderArgs) => ReactNode)Shown when access === 'blocked'. The render function receives { result, open }open() triggers paywall.open().
loadingReactNodeShown while getAccess() hasn’t returned (initial fetch / Provider mount). Defaults to null.
openOnBlockedbooleanAutomatically call paywall.open() on blocked. Defaults to false — most hosts want an explicit CTA click first.
interface BlockedRenderArgs { result: Extract<PaywallAccessResult, { access: 'blocked' }>; open: () => void; }

Patterns

<PaywallGate fallback={<UpgradeCTA />}> <PremiumFeature /> </PaywallGate>

Static fallback. The button inside <UpgradeCTA> calls usePaywall().open() itself.

<PaywallGate> covers 80% of gating. For the rest, drop down to usePaywallAccess — the component is deliberately not configurable for every edge case.

<PaywallButton>

Sugar over usePaywall().open(). Renders a native <button> by default and forwards every HTML attribute.

<PaywallButton className="btn-primary"> Upgrade </PaywallButton>

Props

PropTypeWhat it does
mode'paywall' \| 'support' \| 'signin' \| 'signup' \| 'auth'What to open. 'paywall' (default) → open(), 'support'openSupport(), 'signin'/'signup'openSignin/openSignup. 'auth' is an alias for 'signin'. Ignored when priceId is set.
priceIdstringDirect checkout: when set, the click calls paywall.checkout(priceId, opts) and skips the layout with the plan list — the host has already picked the plan in its own UI. Overrides mode. See direct checkout below.
render(args: { open, ready, processing }) => ReactElementRender-prop for full control over the trigger element. processing is true while direct-checkout (priceId-mode) is preparing — always false in modal-flow. When set, native <button> props are ignored.
identity / renew / skipTrial / skipVisibilityOpenOptionsForwarded into paywall.open(opts) (or paywall.checkout(priceId, opts) when priceId is set).
...restButtonHTMLAttributes<HTMLButtonElement>className, disabled, aria-*, type, onClick — all forwarded onto the native <button>. onClick is composed with our open handler.

Custom element via render

<PaywallButton render={({ open, ready }) => ( <MyFancyButton onClick={open} disabled={!ready}> Upgrade </MyFancyButton> )} />

Radix-style asChild via render prop. Before Provider mount ready: false — the button should be disabled, otherwise the click is a no-op (no instance yet).

Mode variants

<PaywallButton>Upgrade</PaywallButton> {/* opens the paywall layout */} <PaywallButton mode="support">Need help?</PaywallButton> {/* openSupport() */} <PaywallButton mode="signin">Sign in</PaywallButton> {/* openSignin() */} <PaywallButton mode="signup">Create account</PaywallButton> {/* openSignup() */}

Renewal flow

<PaywallButton renew> Renew subscription </PaywallButton>

The paywall opens, skipping the has_active_subscription check — createCheckout() is called with ignoreActivePurchase: true. Use on the “Renew/Upgrade plan” button in the customer portal.

Direct checkout (priceId)

When you render your own pricing page (cards / table) and want the click to go straight to the hosted checkout — skipping the plan-picker step in the modal — pass priceId:

import { usePaywallPrices, PaywallButton } from '@monetize.software/sdk-react'; function PricingCards() { const { prices } = usePaywallPrices(); return prices?.map((p) => ( <Card key={p.id}> <h3>{p.label}</h3> <PaywallButton priceId={p.id} className="btn-primary"> Get this plan </PaywallButton> </Card> )); }

The button shows busy state automatically. While the SDK is preparing the checkout (bootstrap → gates → createCheckout) — typically 200–500 ms — the button is disabled and exposes aria-busy="true". No modal-flash. Once the hosted-checkout tab opens, the modal mounts directly into the awaiting_payment view. Use the render prop if you need to draw your own spinner:

<PaywallButton priceId={p.id} render={({ open, ready, processing }) => ( <MyFancyButton onClick={open} disabled={!ready || processing}> {processing ? <Spinner /> : 'Get this plan'} </MyFancyButton> )} />

What the modal still does for you, on demand:

  • Auth-gate when checkout_mode: 'preauth' is configured and there’s no signed-in user — the signin form appears, and after success the flow auto-resumes into /start-checkout.
  • Popup-blocked fallback — if window.open() returned null (lost transient activation after async signin, aggressive mobile blockers), the modal shows an inline “Open checkout” retry under a fresh user gesture.
  • Awaiting-payment screen — the modal stays mounted while the user pays in the new tab, with an “I’ve paid” verify button and “Open checkout again” recovery.

Without a modal at all:

  • Already-paid — if the user already has an active subscription (detected via cached user, fresh bootstrap, the preauth signin, or a 409 hasActivePurchase from the backend), the SDK emits purchase_completed { restored: true } and stays headless. The host surfaces that however it likes (toast, redirect, badge update via usePaywallUser).
  • Errorserror is emitted (identity.email missing, bootstrap failure, createCheckout rejection); the modal never appears.

Offers (usePaywallOffer(priceId)) automatically thread their offerId into /start-checkout — required for duration_minutes-offers whose countdown lives in clientStorage (the backend can’t validate the timer, so without an explicit offerId the discount would not apply on the hosted checkout).

Under the hood this calls paywall.checkout(priceId, opts) — the same OpenOptions apply (identity, renew, skipTrial, skipVisibility).

priceId overrides mode. A button is either a layout-opener or a direct-checkout trigger — combining both makes no sense (the host has already picked the plan), so when priceId is set, mode is ignored.

mode="auth" is retained as an alias for 'signin' for backwards compatibility — prefer 'signin' in new code. For anonymous sign-in, don’t use a button — call usePaywall().signInAnonymously() directly (headless, no modal; the host owns the loading state on its own button).

<PaywallSupportButton>

Shortcut for <PaywallButton mode="support">. Useful for UI conventions (“Help” in a page corner).

<PaywallSupportButton> Need help? </PaywallSupportButton>

Takes the same HTML attributes as <PaywallButton>, minus mode (fixed) and renew/skipTrial (irrelevant for support).

Composition: feature gate + CTA

A typical shape for a premium page:

function AISummaryPage() { return ( <main> <h1>AI Summary</h1> <PaywallGate loading={<Skeleton />} fallback={({ open }) => ( <section> <p>This feature is included in Pro.</p> <PaywallButton>Start free trial</PaywallButton> </section> )} > <AISummaryWidget /> </PaywallGate> <footer> <PaywallSupportButton>Help</PaywallSupportButton> </footer> </main> ); }

<PaywallGate> controls access, <PaywallButton> opens the modal, <PaywallSupportButton> is a separate path to support. No useEffect chains, everything declarative.

SSR

All three components render safely on the server:

  • <PaywallGate> renders the loading prop (or null) — paywall.getAccess() is not invoked on the server.
  • <PaywallButton> renders a native <button> with disabled until the instance is ready.
  • <PaywallSupportButton> — same.

After the Provider mounts on the client, usePaywallAccess() fires its first getAccess() and the tree re-renders.

Next steps

  • Hooks — the primitives underneath the components; use them for custom scenarios.
  • Quickstart Web — end-to-end React integration.
  • Customer portal — list/cancel/renew without <PaywallGate>.