React (any framework)
This page shows you how to integrate the Unchurn widget into a React app that is not on Next.js — Vite, Remix, Create React App, Astro, or any other bundler. You provide a token endpoint from your own backend (Express, Hono, FastAPI, Rails, or any HTTP server); the useUnchurn hook handles the rest.
If you are on Next.js App Router, see Next.js App Router instead.
Prerequisites
- React 18 or later
@unchurn.dev/widgetinstalled (pnpm add @unchurn.dev/widget)- A backend server you control that can run Node 18+ (or any language — the signing primitives are also available standalone)
UNCHURN_SECRETandUNCHURN_MERCHANT_IDset in your server’s environment
1. Add the token endpoint to your backend
The widget needs a server-side endpoint that mints a signed token. signPayload and encodePayload from @unchurn.dev/widget/nextjs are Node 18+ functions — they work in Express, Hono, Fastify, or any Node runtime. They have no Next.js dependency despite the export path name.
// server/routes/unchurn-token.ts (Express example)
import express from 'express'
import { encodePayload, signPayload } from '@unchurn.dev/widget/nextjs'
const router = express.Router()
router.post('/api/unchurn/token', async (req, res) => {
// Replace with your own session/auth logic.
const user = req.session?.user
if (!user?.stripeSubscriptionId) {
res.status(401).end()
return
}
const mode: 'test' | 'live' = process.env.NODE_ENV === 'production' ? 'live' : 'test'
const expMs = Date.now() + 5 * 60 * 1000 // 5-minute TTL
const merchantId = process.env.UNCHURN_MERCHANT_ID!
const secret = process.env.UNCHURN_SECRET!
const payloadB64 = encodePayload({
merchantId,
subscriptionId: user.stripeSubscriptionId,
mode,
expMs,
})
const sig = signPayload(secret, payloadB64)
const prefix = mode === 'test' ? 'unch_test_' : 'unch_live_'
const authToken = `${prefix}${payloadB64}.${sig}`
res.json({
authToken,
expiresAt: new Date(expMs).toISOString(),
merchantId,
subscriptionId: user.stripeSubscriptionId,
mode,
})
})
export default routerThe response shape must match exactly: authToken, expiresAt, merchantId, subscriptionId, mode. The hook validates this at runtime via zod and surfaces any mismatch as hook.error. See Auth tokens reference for the full token format.
Verify: curl -X POST http://localhost:3001/api/unchurn/token with a valid session cookie should return the five-field JSON object.
2. Use the hook in your React component
Point tokenEndpoint at the URL of the endpoint you created in step 1. The hook prefetches the token on mount and treats tokens within 30 seconds of expiry as stale, refetching on the next show() call.
// src/components/CancelButton.tsx
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, error, refresh }. isReady flips true once the first token fetch settles — use it to disable the button during load. See Widget API reference for the full return surface.
Verify: On mount, your browser’s Network tab should show a POST to /api/unchurn/token that returns 200. Clicking the button should open the cancel flow overlay.
3. Cross-origin: use a same-origin proxy
If your React app is served from app.example.com and your API runs on api.example.com, the useUnchurn hook does not send cookies cross-origin — it calls fetch without credentials: 'include'. The supported pattern is to expose a same-origin proxy route on the app’s domain that forwards to your API.
// app.example.com/api/unchurn/token (proxy route on the same origin as your React app)
import express from 'express'
const router = express.Router()
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())
})
export default routerThen point tokenEndpoint: '/api/unchurn/token' at the proxy. Cookies are sent same-origin to the proxy and forwarded to your API server-to-server.
Common pitfalls
UNCHURN_SECRET in the browser bundle. This secret must stay on the server. Never import it into a React file or a Vite module that ships to the browser. The signing step happens entirely inside your Express handler.
Response shape mismatch. The hook validates the token endpoint response with zod. If your server returns a different field name (e.g. token instead of authToken), hook.error will report a schema parse failure. Match the five fields listed in step 1 exactly.
Cookie not forwarded. Browsers block cross-site cookies by default. If your auth relies on cookies and the API is on a different domain, ensure SameSite=None; Secure is set on the session cookie and that your CORS policy allows credentials.
Next steps
- Auth integrations —
resolveUserpatterns for Clerk, NextAuth, Supabase, and custom tokens - Auth tokens reference — full token format, TTL options, and verification
- Configuration — offer options, theming, and copy overrides