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@latestinstalled.- A server token endpoint that returns
{ authToken, expiresAt, merchantId, subscriptionId, mode }. UNCHURN_SECRETandUNCHURN_MERCHANT_IDset 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 routerVerify 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 constIf 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 }.
| Option | Type | Default | Description |
|---|---|---|---|
tokenEndpoint | string | required | URL of your token endpoint |
lazy | boolean | true | Defer token + runtime preload to the first show() call. Set false to fetch on mount for faster click response. |
baseUrl | string | hosted API | Override the Unchurn API base URL |
scriptUrl | string | hosted CDN | Override the runtime script URL |
fetchImpl | typeof fetch | global fetch | Custom fetch implementation |
defaultFlow | WidgetOpenOptions | — | Default per-open widget options |
| Return | Type | Description |
|---|---|---|
show | (opts?) => Promise<void> | Open the widget. Fetches token + loads runtime on first call. |
close | () => void | Close the widget if open. |
isReady | boolean | false only during eager preload (lazy: false). Always true in lazy mode. |
isOpening | boolean | true 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. |
error | Error | null | Last 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_SECRETin the browser bundle. Keep the secret on the server. Never import it into a React file or expose it withNEXT_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/canceldirectly, remove that handler — Unchurn owns the cancel call after the flow completes. Two handlers will fight.
Where to go next
- Auth integrations —
resolveUserexamples for Clerk, NextAuth, Supabase, custom auth. - Appearance — full theming reference.
- Configuration — open options, callbacks, copy overrides.
- Server integration — token routes for every framework.