Skip to Content
WidgetReact

React

Add a Cancel button to your React app that opens the retention flow. The React package gives you a drop-in component (<UnchurnTrigger>) and a hook (useUnchurn) for when you need to render your own button. Both fetch a signed token from your server, load the hosted widget, and report outcomes through callbacks.

For server-side token routes (Next.js, Remix, SvelteKit, Hono, Express, Fastify, Koa, Lambda, Workers, Edge), see Server integration — the React code on this page works the same way once you have a token route.

Prerequisites

  • React 18 or later.
  • @unchurn.dev/widget@latest installed.
  • A server token endpoint that returns { authToken, expiresAt, merchantId, subscriptionId, mode }.
  • UNCHURN_SECRET and UNCHURN_MERCHANT_ID set on your server (never in browser code).

1. Sign tokens on your server

The browser must never see your signing secret. Use @unchurn.dev/widget/server from any JS runtime — createUnchurnHandler for Fetch-API frameworks, mintUnchurnToken for Node-http frameworks. See Server integration for every framework. Express example:

import express from 'express' import { mintUnchurnToken } from '@unchurn.dev/widget/server' const router = express.Router() router.post('/api/unchurn/token', async (req, res) => { const user = req.session?.user if (!user?.stripeSubscriptionId) return res.status(401).end() const token = mintUnchurnToken({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, subscriptionId: user.stripeSubscriptionId, mode: process.env.NODE_ENV === 'production' ? 'live' : 'test', }) res.json(token) }) export default router

Verify it works: curl -X POST your token endpoint with a valid session and the Network tab should show a 200 response carrying the five fields above.

2. Drop in UnchurnTrigger

The component is the simplest path. It defers the token and runtime fetch until the user clicks Cancel — drop it on any account page (including ones with free-plan users) without conditional rendering. Opt into eager preload with lazy={false} if the cancel flow is the page’s primary action and you want a sub-200ms click response.

'use client' import { UnchurnTrigger } from '@unchurn.dev/widget/react' export function CancelButton() { return ( <UnchurnTrigger tokenEndpoint="/api/unchurn/token" appearance={{ variables: { colorPrimary: '#1942F5', colorPrimaryForeground: '#ffffff', fontFamily: 'inherit', }, }} > Cancel subscription </UnchurnTrigger> ) }

3. Match your brand

appearance sets widget-only theme tokens. It never touches your body, html, :root, or Tailwind layers.

const appearance = { theme: 'auto', variables: { colorPrimary: 'hsl(var(--primary))', colorPrimaryForeground: 'hsl(var(--primary-foreground))', borderRadius: 'var(--radius)', fontFamily: 'inherit', }, } as const

If your app manages its own light/dark state instead of following the OS, pass that state in:

<UnchurnTrigger tokenEndpoint="/api/unchurn/token" appearance={{ theme, /* … */ }} > Cancel subscription </UnchurnTrigger>

See Appearance for the full token list and dark-mode guidance.

4. Use your existing button with asChild

If you already have a styled cancel button, wrap it instead of replacing it. UnchurnTrigger preserves the child’s onClick and respects event.preventDefault().

<UnchurnTrigger tokenEndpoint="/api/unchurn/token" asChild> <button className="danger-button">Cancel subscription</button> </UnchurnTrigger>

5. The hook, for custom UIs

Reach for useUnchurn when the trigger component doesn’t fit — typically when your cancel UI lives somewhere far from the button (a dropdown menu item, a confirmation dialog, a route guard), or when you need to react to readiness and errors in your own state.

'use client' import { useUnchurn } from '@unchurn.dev/widget/react' export function CancelButton() { const { show, isReady, error } = useUnchurn({ tokenEndpoint: '/api/unchurn/token', }) if (error) { return <p>Could not load cancel flow. Reload the page and try again.</p> } return ( <button disabled={!isReady} onClick={() => void show()}> Cancel subscription </button> ) }

The hook returns { show, close, isReady, isOpening, error }.

OptionTypeDefaultDescription
tokenEndpointstringrequiredURL of your token endpoint
lazybooleantrueDefer token + runtime preload to the first show() call. Set false to fetch on mount for faster click response.
baseUrlstringhosted APIOverride the Unchurn API base URL
scriptUrlstringhosted CDNOverride the runtime script URL
fetchImpltypeof fetchglobal fetchCustom fetch implementation
defaultFlowWidgetOpenOptionsDefault per-open widget options
ReturnTypeDescription
show(opts?) => Promise<void>Open the widget. Fetches token + loads runtime on first call.
close() => voidClose the widget if open.
isReadybooleanfalse only during eager preload (lazy: false). Always true in lazy mode.
isOpeningbooleantrue between a show() call and its resolution — token fetch + runtime load + widget mount. Use it to disable your trigger and render a loading affordance during the ~200–450ms click→open window in lazy mode. <UnchurnTrigger> consumes it automatically.
errorError | nullLast network/runtime error from preload or show().

Pass per-open options into show():

void show({ onComplete(outcome) { if (outcome.type === 'canceled') router.refresh() }, })

Custom loading affordance

When you use the hook directly (without <UnchurnTrigger>), gate your trigger on isOpening:

const { show, isOpening } = useUnchurn({ tokenEndpoint: '/api/unchurn/token' }) return ( <button disabled={isOpening} onClick={() => show()}> {isOpening ? 'Opening…' : 'Cancel subscription'} </button> )

Cross-origin token endpoints

The hook calls fetch(tokenEndpoint, { method: 'POST' }) without credentials: 'include'. For cookie-based auth, host the token endpoint on the same origin as your app or proxy through a same-origin route:

// app.example.com/api/unchurn/token router.post('/api/unchurn/token', async (req, res) => { const upstream = await fetch('https://api.example.com/api/unchurn/token', { method: 'POST', headers: { cookie: req.headers.cookie ?? '' }, }) res.status(upstream.status).send(await upstream.text()) })

Content Security Policy

The React package loads the hosted runtime from cdn.unchurn.dev.

Content-Security-Policy: script-src 'self' https://cdn.unchurn.dev; connect-src 'self' https://app.unchurn.dev; style-src 'self' 'unsafe-inline';

Common pitfalls

  • UNCHURN_SECRET in the browser bundle. Keep the secret on the server. Never import it into a React file or expose it with NEXT_PUBLIC_.
  • Response shape mismatch. The SDK validates the token endpoint response and rejects anything that isn’t exactly authToken, expiresAt, merchantId, subscriptionId, mode.
  • An old cancellation handler still runs. If your cancel button used to POST /billing/cancel directly, remove that handler — Unchurn owns the cancel call after the flow completes. Two handlers will fight.

Where to go next