@vibemonetize/react
React SDK for VibeMonetize. Works identically in Next.js App Router and Vite — no
next/* imports anywhere in this package. Client components are marked "use client",
which is a no-op outside Next.
Fails open in the UI in two ways:
- If the Monetization API is unreachable (
degraded), gates render as if every feature were enabled rather than locking users out of an app whose billing backend is temporarily down. - If there is no ancestor
<MonetizeProvider>at all (unconfigured— e.g. you're iterating on an app before wiring up monetization), hooks/components degrade to the same fail-open, no-op state instead of throwing. Pass{ strict: true }to a hook if you'd rather it throw in that case (see below).
The real, fail-closed enforcement always happens server-side via
@vibemonetize/server's requireEntitlement.
Install
pnpm add @vibemonetize/react
No Tailwind CSS in your app? Also import "@vibemonetize/react/styles.css"; once (e.g.
in app/layout.tsx or main.tsx) so components render styled out of the box — see
Theming.
Quick start
<MonetizeProvider> itself works identically in both frameworks, but the three props
below are read from different env var names per framework — Next.js inlines
process.env.NEXT_PUBLIC_*, Vite inlines import.meta.env.VITE_* — so the two snippets
are not interchangeable. (npx vibemonetize init writes the framework-correct one of
these for you automatically — this is what to expect it to generate.)
Next.js App Router — app/layout.tsx is a Server Component, so <MonetizeProvider>
(a "use client" component) is usually wrapped in its own small client boundary file,
conventionally app/providers.tsx:
// app/providers.tsx
"use client";
import { MonetizeProvider } from "@vibemonetize/react";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<MonetizeProvider
appId={process.env.NEXT_PUBLIC_VIBEMONETIZE_APP_ID!}
apiUrl={process.env.NEXT_PUBLIC_VIBEMONETIZE_API_URL!}
publishableKey={process.env.NEXT_PUBLIC_VIBEMONETIZE_PUBLISHABLE_KEY!}
>
{children}
</MonetizeProvider>
);
}
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Vite — wrap the element passed to .render(...) in src/main.tsx directly (no
separate boundary file needed — there's no Server/Client Component split to work around):
// src/main.tsx
import { MonetizeProvider } from "@vibemonetize/react";
ReactDOM.createRoot(document.getElementById("root")!).render(
<MonetizeProvider
appId={import.meta.env.VITE_VIBEMONETIZE_APP_ID}
apiUrl={import.meta.env.VITE_VIBEMONETIZE_API_URL}
publishableKey={import.meta.env.VITE_VIBEMONETIZE_PUBLISHABLE_KEY}
>
<App />
</MonetizeProvider>,
);
publishableKey is a Stripe-style browser-safe key (must start with pk_) sent as
Authorization: Bearer <publishableKey> on every request this SDK makes. Never put your
secret key (sk_..., used by @vibemonetize/server) here, since it would be visible to
every visitor — the provider throws at init on an sk_ key rather than ship it to the
browser. A blank or malformed key (neither pk_ nor sk_ — the default state right
after vibemonetize init, before you've filled in .env.local) is harmless, so instead
of white-screening your app the provider warns once and renders fail-open/unconfigured:
every gate renders unlocked and no entitlements are fetched until you supply a real pk_
key. This is the same silent-fail-open state the SDK enters when the API is unreachable —
and in both cases the SDK now console.warns once so you (or your coding agent) can
tell that what looks like a working paywall is currently a no-op. A well-formed but
wrong/revoked pk_... key (the API answers 401/403) gets its own distinct,
labeled warning — [vibemonetize] publishable key rejected (HTTP 401/403) ... — instead
of being folded into the generic "API unreachable" message, since the two causes call for
different fixes.
import { Paywall, CheckoutButton, UsageBadge, useFeature } from "@vibemonetize/react";
function ExportPanel() {
return (
<Paywall feature="export" fallback={<CheckoutButton planSlug="pro">Upgrade to export</CheckoutButton>}>
<ExportButton />
</Paywall>
);
}
function AskQuestionPanel() {
// Blocks once the "questions" meter's remaining usage hits zero — first-class
// "block at cap" instead of hand-rolling useMeter().remaining <= 0.
return (
<Paywall meter="questions" fallback={<CheckoutButton planSlug="pro">Upgrade for more questions</CheckoutButton>}>
<AskQuestionButton />
</Paywall>
);
}
function SongsUsage() {
return <UsageBadge meter="songs" />;
}
function MaybeExport() {
const { enabled, loading } = useFeature("export");
if (loading) return null;
return enabled ? <ExportButton /> : null;
}
Public API
<MonetizeProvider appId apiUrl publishableKey endUserId? anonId? children>
Mints and background-refreshes an entitlement token (~80% of its TTL before expiry),
authenticating every request with publishableKey (see above). For anonymous/pre-signup
visitors (no endUserId prop), mints and persists an anonId in localStorage under
the key vibemonetize_anon_id (exported as ANON_ID_STORAGE_KEY) so trials survive
reloads. On network failure, retries with backoff and marks the context degraded (see
hooks below) rather than throwing.
If you omit endUserId, the provider also checks localStorage for a previously
verified identity (vibemonetize_end_user_id, exported as END_USER_ID_STORAGE_KEY) —
written by <LoginGate mode="verify-email">'s identify/verify flow — and prefers that
over minting a new anonId, so a verified sign-in survives reloads without your app
managing any state itself. An explicit endUserId prop always wins over the persisted
value. The context also exposes a low-level setEndUserId(id) (see useEntitlements)
that persists and adopts a new identity immediately, refreshing the token right away —
most apps never call this directly; it's what powers <LoginGate mode="verify-email">.
anonId — bringing your own deterministic anonymous identity
Pass anonId when your own backend has already computed a deterministic anonymous
identity for this visitor, instead of letting the provider mint a random one — e.g. the
platform dashboard maps a signed-in developer to a stable, server-derived anonId for
the vibemonetize platform app itself:
<MonetizeProvider
appId="vibemonetize"
apiUrl={apiUrl}
publishableKey={publishableKey}
anonId={platformAnonIdForDeveloper} // computed server-side, deterministic per developer
>
<Dashboard />
</MonetizeProvider>
- Takes precedence over the provider's self-generated/persisted
anonId— no random id is minted, and nothing is written tolocalStorageunderANON_ID_STORAGE_KEYfor it. It's assumed to be server-owned: your backend recomputes the same value on every load, so there's nothing for the browser to persist. - Changing the prop's value (e.g. a different developer signs in) immediately refetches
the entitlement token for the new identity — the same identity-switching machinery
setEndUserIduses. - An explicit
endUserId(prop, or a previously-verified identity from<LoginGate mode="verify-email">) still always wins overanonId— once a real end user is known, the anonymous identity (self-generated or explicit) is irrelevant, same as the existingendUserId-over-self-generated-anonIdprecedence.
creditMeter/featureCosts — a shared credit bank for many differently-priced buttons
If your app has lots of buttons/actions that should each debit a different number of
credits from ONE shared meter (a "credit bank" — e.g. "generate image" costs 5, "export
PDF" costs 1, all against one credits meter), declare the map once on the provider
instead of wiring up useRecordUsage per feature:
<MonetizeProvider
appId={appId}
apiUrl={apiUrl}
publishableKey={publishableKey}
creditMeter="credits"
featureCosts={{ "generate-image": 5, "export-pdf": 1, "remove-background": 2 }}
>
<App />
</MonetizeProvider>
Then useCreditAction("generate-image")/<CreditActionButton feature="generate-image">
look up the declared cost and current affordability for you — see
Credit actions below. Both props are optional and fully
backward-compatible; omitting them leaves every other hook/component unaffected.
featureCosts values must be non-negative, finite numbers — an invalid one (negative or
non-finite) is coerced to 0 with a one-shot console.warn rather than silently
breaking the affordability check.
Provider-less ("unconfigured") behavior
Every hook and component in this package works even if <MonetizeProvider> isn't
mounted yet (or your app can run fully unconfigured) — no need for awkward conditional
wrappers. Outside a provider, hooks report unconfigured: true and fail open the same
way they do when degraded:
| Hook/component | Unconfigured behavior |
|---|---|
useFeature |
{ enabled: true, loading: false, unconfigured: true } |
useMeter |
{ cap: null, remaining: null, ..., unconfigured: true } (unknown) |
useMeterGate |
{ blocked: false, remaining: null, loading: false } (not blocked) |
useCreditAction |
{ cost, meter: null, remaining: null, canAfford: true, blocked: false } — spend() warns once and no-ops (no creditMeter configured) |
<Paywall> |
renders children (never fallback/remote config) |
<CheckoutButton> |
renders disabled and console.warns once |
If you have code that genuinely requires a provider to be present, pass
{ strict: true } to useEntitlements/useFeature/useMeter/useMeterGate/
useRecordUsage/useCreditAction/useMonetizeContext to restore the old throwing behavior:
const { enabled } = useFeature("export", { strict: true }); // throws outside <MonetizeProvider>
Hooks
useEntitlements(options?)→{ appId, apiUrl, publishableKey, endUserId, anonId, claims, loading, degraded, unconfigured, error, refresh(), recordUsage(), setEndUserId() }— the full context.setEndUserId(id: string | null)is the low-level identity switch described under<MonetizeProvider>above.useFeature(slug, options?)→{ enabled, loading, unconfigured }—enabledisfalsewhile loading, and fails open (true) once the provider isdegradedorunconfigured.useMeter(slug, options?)→{ cap, used, remaining, resetsAt, loading, unconfigured, status }—cap/remainingarenullunlessstatusis"capped".status(MeterStatus, added S2-B, buyer-loop cycle 4) discriminates why:"loading"(first fetch pending),"unconfigured"(no provider),"unknown"(claims failed to load — degraded/API unreachable, genuinely don't know),"uncapped"(claims loaded fine, butslugisn't in the token at all — an AFFIRMATIVE "no cap applies", e.g. a free tier with no limit configured for this meter — not a loading/degraded state; also covers a typo'dslugmatching no meter the app has, which the client can't tell apart from a real uncapped meter), or"capped"(a real cap;cap/used/remaining/resetsAtare the real numbers).<UsageBadge>usesstatusto render "Unlimited" instead of nothing for"uncapped"— see below.useMeterGate(slug, options?)→{ blocked, remaining, loading }— first-class "block at cap":blockedistrueonceremainingis a known number<= 0, andfalse(fail open) while loading or wheneverremainingis unknown. Powers<Paywall meter="...">and is available standalone for custom gating UI.const { blocked, remaining } = useMeterGate("questions"); if (blocked) return <UpgradeCta />;useRecordUsage(slug, options?)→(delta?: number, options?: { idempotencyKey?: string }) => void— fire-and-forget records usage against meterslug(POST /v1/usage-events, defaultdelta1), optimistically decrementing the cached meter souseMeter/<UsageBadge>update immediately (PLAN.md: client optimistic, server reconciles on the next entitlement-token refresh). Never throws.const recordSongUsage = useRecordUsage("songs"); <button onClick={() => recordSongUsage()}>Generate song</button>;Dedupe against server-side metering. Pass
idempotencyKeywhen your own backend also meters the same event authoritatively (e.g.@vibemonetize/server'smeterUsage). Fire both calls with the same key: the API dedupes them into a single increment, so the UI still updates optimistically the moment the user acts, without waiting on your backend's round trip, while the server only counts the usage once.const recordSongUsage = useRecordUsage("songs"); async function generateSong() { const idempotencyKey = crypto.randomUUID(); recordSongUsage(1, { idempotencyKey }); // optimistic UI update, fire-and-forget await fetch("/api/generate-song", { method: "POST", headers: { "x-idempotency-key": idempotencyKey }, }); // your backend meters authoritatively with the same key via meterUsage() }useCreditAction(featureSlug, options?)→{ cost, meter, remaining, canAfford, blocked, spend(spendOptions?) }— DX sugar overuseMeter/recordUsagefor the shared-credit-bank pattern (seecreditMeter/featureCostsabove), built onuseMeter(creditMeter)the same wayuseMeterGateis built onuseMeter.costis the declared price forfeatureSlug(nullif it's not infeatureCosts— distinct from a legitimately declared0).blockedistrueonly onceremainingis a KNOWN number belowcost— never merely because something is still loading, degraded, or unconfigured (fail open, same philosophy asuseMeterGate).spend({ idempotencyKey? })fires the same optimistic-decrement/fire-and-forgetrecordUsagecalluseRecordUsagemakes, with the meter slug and delta looked up for you:- Returns
falseand does nothing ifblocked(a real, known insufficient balance). - Returns
false, does nothing, andconsole.warns once iffeatureSlughas no declared cost, or if nocreditMeteris configured — both are developer misconfiguration, not a legitimate afford/deny outcome. - Otherwise debits
costcredits and returnstrue.
function GenerateImageButton() { const { cost, blocked, spend } = useCreditAction("generate-image"); return ( <button disabled={blocked} onClick={() => spend() && startGeneration()}> Generate image ({cost} credits) </button> ); }This is client-side UX sugar over the existing advisory-client/authoritative-server split — it does not enforce anything server-side. Pair it with
@vibemonetize/server'smeterUsageusing the samecreditMeterslug and delta to authoritatively meter the same spend; per-feature spend attribution in analytics (i.e. knowing which feature a given debit came from, beyond the raw meter total) is a documented follow-up, not part of this change.- Returns
useMonetizeContext(options?)— the low-level context accessor the above hooks are built on; prefer the specific hooks above.options?: { strict?: boolean }on every hook controls provider-less behavior (see above); defaultstrict: false.useEntitlementToken(options?)→() => Promise<string | null>— resolves the current raw entitlement JWT (the same signed string<MonetizeProvider>fetched fromPOST /v1/entitlement-token), refreshing it first if it's missing or close to expiry. Forward it to your own backend so it can verify it locally with@vibemonetize/server'srequireEntitlement— the "songlens pattern" of sending it as anx-vibemonetize-tokenrequest header, enforcing access without round-tripping through the Monetization API on every request. Never throws; resolvesnullif no valid token is available (offline with nothing cached, orunconfigured). Concurrent calls (including racing the provider's background refresh) dedupe onto a single in-flight request.const getToken = useEntitlementToken(); async function askQuestion(prompt: string) { const token = await getToken(); return fetch("/api/ask", { method: "POST", headers: { "content-type": "application/json", ...(token ? { "x-vibemonetize-token": token } : {}), }, body: JSON.stringify({ prompt }), }); }claims(fromuseEntitlements) is decoded but unverified client-side and must never be trusted as an access-control decision on its own —getToken()/useEntitlementTokenis the supported way to get the token itself out of the SDK for your backend to verify.useUpgradeModal(initialOpen?)→{ open, openModal(), closeModal(), toggleModal() }— plain local UI state for<UpgradeModal>; doesn't touch<MonetizeProvider>at all, so it works anywhere, including outside a provider.useMode(options?)→Mode | null— the developer test/live split (RFC 012): themodeclaim ("test" | "live") off the current entitlement token, ornullbefore the first fetch resolves or outside<MonetizeProvider>. Purely informational (never itself an access-control decision, same caveat asclaimsabove) — powers<TestModeBadge>, or wire it into your own banner:const mode = useMode(); if (mode === "test") return <span>Test mode</span>;
Components
<Paywall feature|meter fallback? children>— gate on exactly one offeature(enabled/disabled flag) ormeter(blocks onceremaining <= 0, viauseMeterGate); passing both or neither throws (a coding mistake, not a runtime condition). Renderschildrenwhile the gate is open. While blocked it renders, in order of precedence: thefallbackprop (an explicit fallback — evennull— always wins) → the paywall published in the dashboard's paywall editor (via<PaywallGateView>, see below — remote config, so copy/accent changes need no app redeploy) → nothing (no published config = exactly the pre-remote-config behavior). Fires apaywall_impressionanalytics event (fire-and-forget,POST /v1/events,properties: { feature }or{ meter }) the first time it blocks for a givenfeature/meter.<PaywallGateView content plans? preview? className?>— the rendered remote-config paywall itself: the published headline/body (paywallContentV1Schemafrom core, the same shape the dashboard editor writes) with one CTA per public plan (<CheckoutButton>for subscriptions,<BuyCreditsButton>for credit packs). Omitplansto self-fetchGET /v1/public/plans(the<PricingPage>pattern; fail-quiet).previewrenders inert CTAs with no fetching — the dashboard's live preview uses this, so the preview is the production markup. The configuredaccentColoris applied as--vibemonetize-primary/--vibemonetize-primary-hover.- Powered-by footer (RFC 021). Whenever
content.showPoweredByistrue(the default —paywallContentV1Schema.showPoweredBy,.default(true)so configs persisted before this key existed still resolve to "on"), the gate renders a small footer: plain text "Powered by" followed by a brand-name-only anchor tohttps://vibemonetize.dev/?utm_source=paywall&utm_medium=powered-by— a normal, followed link (norel="nofollow"; onlyrel="noopener"alongsidetarget="_blank", a security convention unrelated to link equity).showPoweredBy: false(theremove_brandingplan feature, checked upstream via the paywall-config write path — not by this component) renders nothing.previewmode shows the footer identically to production, since the dashboard's live preview is this component.<PaywallGateView content={{ ...content, showPoweredBy: false }} plans={plans} />
- Powered-by footer (RFC 021). Whenever
<LoginGate mode? fallback? onVerified? className? children>— renderschildrenonly onceendUserIdis known (a signed-in user).mode="identity"(default): anonymous visitors seefallback.mode="verify-email"(RFC 002): anonymous visitors see a built-in, two-step email → 6-digit-code sign-in form instead offallback(which is ignored in this mode) —POST /v1/end-users/identifymints a code,POST /v1/end-users/verifyredeems it. On success the SDK stores the returnedendUserIdinlocalStorage(END_USER_ID_STORAGE_KEY), switches<MonetizeProvider>'s identity fromanonIdto it, and refreshes the entitlement token immediately, sochildrenrenders right after —onVerified(endUserId)fires alongside that. Handles a wrong code (generic "incorrect code" message, never reveals attempts remaining), an expired/consumed challenge (offers a one-click resend), and rate limiting (a gentle "try again shortly" message) — none of these throw.<LoginGate mode="verify-email"> <FreeTrialApp /> </LoginGate>
<CheckoutButton planSlug|planId successUrl? cancelUrl? appendSuccessParam? showPrice? className? onError? children?>—POST /v1/checkout-sessions, then redirects the browser (window.location.assign) to the returned hosted checkout URL. Name the plan by exactly one of (RFC 006):planSlug(preferred) — the slug you chose in the dashboard's pricing editor ("pro","credits-100"). Stable across environments and re-seeds, so it can be written straight into JSX; resolved server-side within<MonetizeProvider appId>(slugs are unique per app — the SDK sends theappIdscope for you).planId— the plan's ULID; the escape hatch, and the only way to sell a cross-app bundle plan (bundle plans have no single app scope).
The resolved public plan price is appended to the button label by default (for example,
Upgrade to Pro — $5/mo), including when you provide custom children. Free plans and unavailable prices keep the original label. PassshowPrice={false}when your own copy already includes the price.cancelUrldefaults to the current page URL, unmodified.successUrldefaults to the current page URL with a detectable?checkout=successquery param appended (S2-A, buyer-loop cycle 4) — pair with<CheckoutSuccessBanner>(below) so a buyer who completes checkout and lands back on your app sees a confirmation instead of a silent redirect to the unchanged page they started on. Passing your ownsuccessUrlfully opts out (nothing is appended to an explicit value); passappendSuccessParam={false}to keep the plain current-page-URL default without the param. Never throws: if the request fails, renders an inline, CSS-var-themeable error message near the button andconsole.warns — passonErrorto handle the failure yourself instead (it fully overrides the inline error/warn). Permanent 4xx refusals render the API's actionable message; network and 5xx failures retain generic retry copy. Typed API refusals passed toonErrorare exportedCheckoutErrorinstances withcode,message, andstatus.<BuyCreditsButton planSlug|planId ...>— a thin<CheckoutButton>variant for credit-pack plans: identical props (including thesuccessUrl/appendSuccessParambehavior above) and graceful error handling, just labeled "Buy credits" by default (override viachildren) and classedvibemonetize-buy-credits-buttonfor separate theming/analytics hooks if you need it.<CheckoutSuccessBanner param? value? children? className? onDismiss? autoRefresh? cleanUrl?>(S2-A, buyer-loop cycle 4) — pairs with<CheckoutButton>/<BuyCreditsButton>'s defaultsuccessUrlabove. On mount, if?checkout=success(or your ownparam/value) is in the current URL: shows a themeable confirmation banner (default copy "Payment successful — you're all set.", override viachildren), fires an immediate entitlement-tokenrefresh()(autoRefresh, defaulttrue— no-op outside<MonetizeProvider>), and strips the param from the URL viahistory.replaceState(cleanUrl, defaulttrue) so a reload/share/back doesn't keep re-showing it. Renders nothing otherwise. Dismissible via its close button (onDismissfires alongside). Detection is a one-shot mount-time check, not a live subscription to URL changes — works identically in Next.js App Router and Vite (window.location/historyonly, no router integration, nonext/*import). Fully optional: mount it once near your app's root, or don't mount it at all for your own post-checkout UX (<CheckoutButton successUrl>still works exactly as before if you pass your own value).<MonetizeProvider appId={appId} apiUrl={apiUrl} publishableKey={publishableKey}> <CheckoutSuccessBanner /> <App /> </MonetizeProvider><UsageBadge meter className? unlimitedLabel?>— a themeable usage pill. Renders nothing while loading,unconfigured, or degraded (genuinely unknown state — seeuseMeter'sstatusabove). Once claims load: capped meters renderused/cap(e.g.4/10); uncapped meters (S2-B, buyer-loop cycle 4 — claims loaded fine, but the meter isn't in the token at all, e.g. an unlimited free tier) renderunlimitedLabel(default"Unlimited") instead of rendering nothing, so a free/anonymous user sees SOME usage state rather than a blank space that looks broken. PassunlimitedLabel={false}to opt back into rendering nothing for the uncapped case (the pre-this-fix behavior).<CreditBalance meter className? fallback?>— shows the remaining balance for a credit-pack meter (built onuseMeter, so it reflectsuseRecordUsage's optimistic decrement immediately as credits are spent). Rendersfallback(default: nothing) while unknown, same as<UsageBadge>.<CreditActionButton feature onSpent? idempotencyKey? disabledWhenBlocked? className? children? ...button>— a credit-aware button built onuseCreditAction: looks upfeature's declared cost, disables itself whenblocked(default; passdisabledWhenBlocked={false}to always render clickable and handle the declined case yourself), and callsonSpent()only after a successfulspend(). Accepts any other native<button>prop (passthrough). Same theming vars as<CheckoutButton>(--vibemonetize-primary/-hover).<MonetizeProvider appId={appId} apiUrl={apiUrl} publishableKey={publishableKey} creditMeter="credits" featureCosts={{ "generate-image": 5, "export-pdf": 1 }} > <CreditActionButton feature="generate-image" onSpent={() => startGeneration()}> Generate image (5 credits) </CreditActionButton> <CreditActionButton feature="export-pdf" onSpent={() => exportPdf()}> Export PDF (1 credit) </CreditActionButton> <CreditBalance meter="credits" /> </MonetizeProvider><PaywallOffers offers className? onError?>— a dumb, presentational helper for a<Paywall>fallback that offers a subscription and a credit pack side by side, e.g. "Upgrade to Pro" next to "Buy 100 credits".offers: { planSlug|planId, label, kind: "subscription" | "credits", description? }[](exactly one plan identifier per offer, same as<CheckoutButton>) — renders<CheckoutButton>for"subscription"and<BuyCreditsButton>for"credits"; both keep their own never-throws error handling.onError(error, offer)reports which offer failed.<Paywall meter="ai-credits" fallback={ <PaywallOffers offers={[ { planSlug: "pro", label: "Pro", kind: "subscription", description: "$9/mo, unlimited" }, { planSlug: "credits-100", label: "100 credits", kind: "credits", description: "$5 one-time" }, ]} /> } > <AskQuestionButton /> </Paywall><PricingPage appId? plans? successUrl? cancelUrl? className? onError?>— a responsive pricing page for one app: subscription plans and credit packs rendered side by side in their own sections (credits are first-class — every monetization surface includes the credits path), each wired to<CheckoutButton>/<BuyCreditsButton>, with the end user's current plan (from the entitlement token'sclaims.plan) highlighted and shown as disabled instead of offering to "upgrade" to what they already have.appIddefaults to the surrounding<MonetizeProvider>'sappId; current-plan highlighting only applies when it matches (the entitlement token only carries plan info for the app it was minted for). Renders nothing onceplansis filtered down to nothing relevant forappId, while the self-fetch is still loading, or if it fails (fail quiet) — bring your own empty state if you need one.plansis optional. Omit it and<PricingPage>self-fetches the app's plans from the browser-safeGET /v1/public/plans?appId=with the surrounding provider's publishable key. Or passplansyourself —PublicPlan[]fetched server-side (Server Components / SSG passing data down; no client fetch happens) or a fullPlan[]from the secret-keyGET /v1/plans(PricingPagePlanaccepts both; full plans keep the oldappId/archivedAtrelevance filtering):// Zero config — self-fetches the provider app's public plans: <PricingPage /> // Server Component / SSG — fetch PublicPlan[] server-side, pass data down: <PricingPage appId={appId} plans={plans} /><AccountButton email? portalUrl? signInFallback? className?>— a small account button/menu: shows the current end user (your suppliedemail, else a shortenedendUserId, else "Anonymous"), a sign-in affordance for anonymous visitors (a built-in<LoginGate mode="verify-email">form by default, or your ownsignInFallback), a "Sign out" action (clears the identity viasetEndUserId(null)), and a "Manage billing" action.<MonetizeProvider>only ever knowsendUserId(an opaque id), never an email, so passemailyourself if your app tracks it.portalUrlis optional (RFC 009). Omit it and "Manage billing" mints a Stripe billing-portal session itself via the browser-safePOST /v1/billing-portal-sessions, proving the identity with the SDK's own entitlement token (the server verifies the token's signature and scopes the portal strictly to itssub— a publishable key alone can never open someone else's portal), then redirects to the returned hosted portal URL. Never throws: a404means this user has no Stripe billing yet (never paid, or comp/free-only) and renders as a muted "No billing to manage yet"; any other failure renders an inline, CSS-var-themeable error (--vibemonetize-error) andconsole.warns. Or passportalUrlyourself (a URL your backend already minted) — the prop always wins and renders a plain link with no request made.// Zero config — mints a portal session via the SDK's own entitlement token: <AccountButton email={user?.email} /> // Or bring your own backend-minted portal URL (always wins): <AccountButton email={user?.email} portalUrl={billingPortalUrl} /><UpgradeModal open onClose feature? title? description? className? children>— a themeable modal wrapper for in-flow upgrade prompts: pass<PricingPage>or<PaywallOffers>aschildrento turn either into a midway "you hit a limit, here's how to upgrade" dialog instead of a dedicated page. Controlled viaopen/onClose— pair withuseUpgradeModal()for simple local state, or wireopenup to your own routing/state. Closes on Escape, a backdrop click, or the close button, always by callingonClose(never by unmounting itself).const { open, openModal, closeModal } = useUpgradeModal(); <Paywall meter="songs" fallback={<button onClick={openModal}>Upgrade</button>}> <GenerateSongButton /> </Paywall> <UpgradeModal open={open} onClose={closeModal} feature="songs"> <PricingPage plans={plans} /> </UpgradeModal><TestModeBadge className? label?>— the developer test/live split (RFC 012): a small pill that renders only whileuseMode()is"test"— nothing outside<MonetizeProvider>, nothing for"live"/null(e.g. a pre-RFC-012 token). Fully optional and self-contained: nothing else in this package or@vibemonetize/serverdepends on it, so mounting it is always backward-compatible/behavior-neutral for an app that has never heard of test mode.<MonetizeProvider appId={appId} apiUrl={apiUrl} publishableKey={publishableKey}> <TestModeBadge /> <App /> </MonetizeProvider>
Theming
Every element these components render carries a stable vibemonetize-* class (e.g.
vibemonetize-checkout-button) in addition to Tailwind default-palette utility
classes with CSS variables for overrides — no custom Tailwind theme extension required.
No Tailwind in your app? Tailwind utility classes produce zero CSS without Tailwind,
so components would render unstyled — import "@vibemonetize/react/styles.css"; once,
anywhere you'd import a global stylesheet (e.g. app/layout.tsx or main.tsx), to get
the same spacing/radius/typography/colors from a small, plain CSS file with no build
step. It targets only the vibemonetize-* classes at single-class specificity, so it
never fights your own overrides. vibemonetize init adds this import for you
automatically when it doesn't detect Tailwind in your project.
Already using Tailwind? Just make sure it scans this SDK — add
"./node_modules/@vibemonetize/react/dist/**/*.js" to your tailwind.config's content,
or its utility classes get purged and components render unstyled. styles.css is
optional in that case; vibemonetize init warns if it can't detect Tailwind (and adds the
import automatically when it can't). If you import styles.css anyway, its values are
chosen to match the Tailwind utilities exactly, so it's a harmless no-op layered
underneath (same computed styles, no visual change, no double-styling).
CSS variables, themeable regardless of which path above you take:
<CheckoutButton>/<BuyCreditsButton>/<CreditActionButton>:--vibemonetize-primary,--vibemonetize-primary-hover,--vibemonetize-error(inline checkout-failure message;<CreditActionButton>doesn't render an inline error itself, just shares the primary/hover vars)<UsageBadge>:--vibemonetize-badge-bg,--vibemonetize-badge-fg<CreditBalance>:--vibemonetize-credit-bg,--vibemonetize-credit-fg<PaywallOffers>:--vibemonetize-offer-border,--vibemonetize-offer-fg,--vibemonetize-offer-muted(plus its buttons' own vars above)<LoginGate mode="verify-email">:--vibemonetize-primary,--vibemonetize-primary-hover,--vibemonetize-error,--vibemonetize-fg,--vibemonetize-muted,--vibemonetize-border(form labels/inputs)<PricingPage>:--vibemonetize-pricing-border,--vibemonetize-pricing-fg,--vibemonetize-pricing-muted,--vibemonetize-pricing-highlight(current-plan border and badge, plus its buttons' own vars above)<AccountButton>:--vibemonetize-account-border,--vibemonetize-account-fg,--vibemonetize-account-hover-bg,--vibemonetize-account-bg,--vibemonetize-account-muted, plus--vibemonetize-primaryfor "Manage billing" (and--vibemonetize-errorfor its inline portal-failure message) and<LoginGate mode="verify-email">'s own vars for the sign-in form<UpgradeModal>:--vibemonetize-modal-overlay,--vibemonetize-modal-bg,--vibemonetize-modal-fg,--vibemonetize-modal-muted,--vibemonetize-modal-border<TestModeBadge>:--vibemonetize-test-badge-bg,--vibemonetize-test-badge-fg<CheckoutSuccessBanner>:--vibemonetize-success-bg,--vibemonetize-success-fg,--vibemonetize-success-border
:root {
--vibemonetize-primary: #16a34a;
--vibemonetize-primary-hover: #15803d;
}
These variables work the same whether or not you import styles.css — set them on
:root (or any ancestor) either way; both the Tailwind utility classes and
styles.css's rules read from the same var(--vibemonetize-x, fallback).
Note: every element rendered by this package now carries a stable
vibemonetize-*class — a handful of previously-unclassed elements picked one up sostyles.css(and your own CSS) has something to target: the plan-name/price/trial text and section headings in<PricingPage>, the offer label/description in<PaywallOffers>, the "Signed in as"/name text in<AccountButton>, the form labels/hint text in<LoginGate mode="verify-email">, and the header/title/description wrapper in<UpgradeModal>. Purely additive — no existing class was renamed or removed.
Known gaps
None currently. (The two historical entries here both closed: <PricingPage>
self-fetches GET /v1/public/plans by default, and <AccountButton>'s "Manage
billing" mints its own portal session via POST /v1/billing-portal-sessions — RFC
009.)
Tests
pnpm test — vitest + jsdom + @testing-library/react, with global.fetch mocked.
Covers anonId minting/persistence, an explicit anonId prop (takes precedence over
self-generated, is never persisted, refetches on change, still loses to endUserId),
endUserId persistence/precedence and setEndUserId's
immediate refresh, feature/meter/meter-gate derivation from claims, the fail-open path
when the API is unreachable, the fail-open ("unconfigured") path outside
<MonetizeProvider> (including strict: true opting back into throwing),
<CheckoutButton>/<BuyCreditsButton>'s inline-error/onError paths, <Paywall>'s
feature/meter modes (including its exactly-one-of runtime check), <PaywallOffers>
routing each offer kind to the right button, <CreditBalance>'s unknown-meter fallback,
<LoginGate mode="verify-email">'s full identify → verify happy path plus wrong-code,
expired-challenge-with-resend, and rate-limited error handling (mocked fetch, no real
API), refresh scheduling, and each component. Also covers <PricingPage>'s
app-filtering/current-plan-highlighting/side-by-side subscription+credit-pack rendering
plus both plans paths (self-fetch from GET /v1/public/plans incl. the fail-quiet
error path, and PublicPlan[] passed directly with no client fetch),
<AccountButton>'s anonymous/signed-in/portal-link/sign-out states plus both "Manage
billing" paths (an explicit portalUrl renders a plain link with no request; without
one it POSTs /v1/billing-portal-sessions with the entitlement token and redirects,
renders "No billing to manage yet" on the typed 404, and an inline error on other
failures), <UpgradeModal>'s
open/close paths (backdrop, close button, Escape) and useUpgradeModal's state,
getToken/useEntitlementToken's fresh/stale/offline/unconfigured/dedupe behavior, and
(RFC 012) useMode/<TestModeBadge> reporting null pre-fetch/outside a provider,
"test"/"live" once claims load, and rendering nothing outside "test" mode. Also
covers (S2-A/S2-B, buyer-loop cycle 4) <CheckoutButton>'s default successUrl
appending ?checkout=success (preserving an existing query string, never touching an
explicit successUrl, and appendSuccessParam={false} opting out),
<CheckoutSuccessBanner>'s param detection/refresh/URL-cleanup/dismiss/custom-copy/
custom-param behavior (including outside <MonetizeProvider>), useMeter's status
discriminant ("loading"/"unconfigured"/"unknown"/"uncapped"/"capped"), and
<UsageBadge> rendering "Unlimited" (or a custom unlimitedLabel) for an uncapped
meter instead of nothing, with unlimitedLabel={false} opting back into the old
render-nothing behavior. Also covers the shared-credit-bank feature: creditMeter/
featureCosts surfacing on context with clean backward-compatible defaults,
useCreditAction's declared-cost lookup and canAfford/blocked transitions
(above/at/below cost, and the unknown/degraded/unconfigured fail-open case), spend()
firing recordUsage with the right meter/delta/idempotencyKey and returning true, its
no-op-and-false paths (blocked, undeclared feature, unset creditMeter), and
<CreditActionButton>'s disabled-when-blocked/click-to-spend/onSpent/passthrough-props
behavior.
SDK compatibility telemetry
Every API request includes x-vibemonetize-sdk: react/<version>. This contains
only the package name and version; it lets VibeMonetize measure which immutable
client versions remain deployed. Response objects tolerate additive fields, and
public plan lists retain compatible plans if a future plan variant is unknown to
the installed SDK.