@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:

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 Routerapp/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>

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

Components

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:

: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 so styles.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.