Package Exports
- @relipay/react
Readme
@relipay/react
React hooks and drop-in UI components for ReliPay end-user auth, organizations, and billing — the browser SDK.
It gives you Clerk-style components (<SignIn>, <UserButton>, <PricingTable>, …) plus the headless primitives (useUser, <SignedIn>, <Protect>) so you can ship auth/billing UI fast, or build your own.
npm install @relipay/react
# or: pnpm add @relipay/react / yarn add @relipay/reactPeer dependency:
react@^18 || ^19. No CSS framework required — the components ship their own tokens-based stylesheet.
The security model (read this first)
The browser never holds your Application secret key. The only ReliPay credential in the browser is the end-user's short-lived JWT (the access token), seeded from your server.
Because of that, the components here are render + delegate:
- They read auth state from
<RelipayProvider>(which calls the user-token-onlyGET /api/v1/auth/me). - For writes — sign-in, sign-up, sign-out, create-org, invite, checkout — they call your server (a Next.js Server Action or a route handler) that runs
@relipay/nodewith the secret key. This is exactly how Clerk's components talk to Clerk's backend; ReliPay just keeps that backend on your server.
Browser (UI components, public)
│ form submit / Server Action
▼
Your server ──@relipay/node + secret──► ReliPay API
│ sets the session cookie
▼
SSR seeds <RelipayProvider> on the next renderSo every component below that mutates something takes an action (or actionUrl) prop — that's your server code.
Setup: <RelipayProvider>
Wrap your app once, seeded from your server session. With Next.js + @relipay/nextjs:
// app/layout.tsx (Server Component)
import { auth } from '@relipay/nextjs/server';
import { RelipayProvider } from '@relipay/react';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await auth(); // { user, accessToken } | null
return (
<html>
<body>
<RelipayProvider
apiUrl={process.env.NEXT_PUBLIC_RELIPAY_URL!}
initialUser={session?.user ?? null}
accessToken={session?.accessToken ?? null}
>
{children}
</RelipayProvider>
</body>
</html>
);
}apiUrl is your ReliPay deployment's base URL — not a secret. The accessToken is the end-user's JWT (safe to expose to client JS for the session; rotate it via cookie on the server).
Public key vs secret key. Your Application's public key (
rp_pub_…) is a browser-safe identifier; the secret key (rp_live_…/rp_test_…) is server-only and powers the Server Actions you pass to these components. The browser SDK authenticates with the user's JWT, not either Application key.
Hooks
'use client';
import { useUser, useRelipay } from '@relipay/react';
function Profile() {
const { user, signedIn, loading } = useUser();
const { refresh } = useRelipay(); // re-fetch the user after a sign-in round-trip
if (loading) return <Spinner />;
if (!signedIn) return <a href="/login">Sign in</a>;
return <p>Hi {user.email}</p>;
}Control components
Gate regions of your UI. No styling, no opinions.
| Component | Renders children when… |
|---|---|
<SignedIn> |
a user is signed in |
<SignedOut> |
no user is signed in |
<RelipayLoading> |
the provider is still resolving the session |
<RelipayLoaded> |
the provider has resolved |
<Protect> |
the supplied entitlement / feature / role check passes |
import { SignedIn, SignedOut, RelipayLoading, RelipayLoaded, Protect } from '@relipay/react';
<RelipayLoading><Spinner /></RelipayLoading>
<RelipayLoaded>
<SignedIn><Dashboard /></SignedIn>
<SignedOut><Landing /></SignedOut>
</RelipayLoaded><Protect> — gate by entitlement / feature / role
Entitlements resolve server-side (@relipay/node billing.getEntitlements) — the browser never re-fetches them with a credential. Pass the resolved facts in via authorization; <Protect> renders the decision your server already made.
// feature flag
<Protect authorization={{ features }} feature="analytics" fallback={<UpgradeCard />}>
<AnalyticsTab />
</Protect>
// role (in the active org)
<Protect authorization={{ role }} role={['OWNER', 'ADMIN']}>
<InviteMembersButton />
</Protect>
// numeric limit, via a predicate
<Protect authorization={{ features }} condition={(a) => Number(a.features?.max_qr_codes) >= 10}>
<BulkTools />
</Protect>Auth widgets
<SignIn> / <SignUp>
Styled cards with email + password, optional magic-link, and optional OAuth buttons. They post to your Server Actions.
'use client';
import { SignIn } from '@relipay/react';
import { signInAction, magicLinkAction, startGoogle } from '@/lib/actions';
<SignIn
action={signInAction} // (formData) => server-side relipay.auth.signIn(...)
magicLinkAction={magicLinkAction}
oauthProviders={[{ provider: 'google', startAction: startGoogle }]}
signUpUrl="/signup"
forgotPasswordUrl="/forgot-password"
/>
<SignUp action={signUpAction} signInUrl="/login" />A matching server action looks like (Next.js App Router):
// lib/actions.ts
'use server';
import { signIn } from '@relipay/nextjs/server';
import { redirect } from 'next/navigation';
export async function signInAction(formData: FormData) {
const email = String(formData.get('email'));
const password = String(formData.get('password'));
await signIn({ email, password }); // sets the session cookie
redirect('/dashboard');
}Not on the App Router? Use actionUrl="/api/sign-in" (the form does a plain POST) and call useRelipay().refresh() after.
<UserButton>
Avatar + dropdown menu (manage account, sessions, sign out). Renders nothing when signed out.
<UserButton
manageAccountUrl="/account"
sessionsUrl="/account#sessions"
signOutAction={signOutAction}
extraItems={[{ label: 'Billing', href: '/billing' }]}
/><SignInButton> / <SignUpButton> / <SignOutButton>
Small affordances. Sign-in/up navigate to a URL; sign-out invokes your action.
<SignedOut>
<SignInButton url="/login" />
<SignUpButton url="/signup" />
</SignedOut>
<SignedIn>
<SignOutButton action={signOutAction} />
</SignedIn>Organization widgets
The org endpoints are secret-key guarded, so these read server-resolved data via props and delegate mutations to your actions.
<OrganizationSwitcher>
Pick / switch / create the active team.
<OrganizationSwitcher
organizations={orgs} // organizations.listMine() server-side
activeOrganizationId={activeOrgId}
switchAction={switchOrgAction} // organizations.switch() + cookie rotation
createAction={createOrgAction}
billingSubject={config.billingSubject} // see org-billing note below
/><CreateOrganization>
<CreateOrganization action={createOrgAction} /><OrganizationProfile>
Members + pending invitations, with role-change / remove / revoke affordances for OWNER/ADMIN viewers.
<OrganizationProfile
organization={{ id: org.id, name: org.name }}
members={members} // organizations.listMembers() server-side
invitations={pendingInvites} // optional
viewerRole={myRole} // gates the manage affordances
inviteAction={inviteMemberAction}
setRoleAction={setRoleAction}
removeAction={removeMemberAction}
hiddenFields={{ orgId: org.id }} // appended to every form
/>Form field contract: invite reads
role; set-role/remove readendUserId; revoke readsinvitationId. Add the org id withhiddenFields.
Billing widgets
<PricingTable>
Renders your plans with upgrade buttons. Plans come from billing.getPlans() (public — no user token needed) fetched server-side and passed in; each upgrade posts to your checkout action.
<PricingTable
plans={plans} // billing.getPlans() server-side
currentPlanSlug={isPro ? 'pro_monthly' : 'free'}
checkoutAction={checkoutAction} // billing.createCheckout() + redirect
hiddenFields={activeOrgId ? { orgId: activeOrgId } : undefined}
orgGateBlocking={config.billingSubject === 'org' && !activeOrgId}
/><CheckoutButton>
A single-plan CTA.
<CheckoutButton planSlug="pro_monthly" action={checkoutAction}>
Upgrade to Pro
</CheckoutButton><ProviderPicker> — let the user choose how to pay
An Application can enable up to 3 billing providers (stripe / paypal / razorpay). Checkout's provider is optional — when you omit it, a server-side geo router auto-picks one. <ProviderPicker> lets the end-user override that pick with a "Pay with…" radio group.
Like <PricingTable plans>, the provider list is a prop you fetch server-side — never from the browser. GET /api/v1/billing/providers (billing.getProviders()) is secret-key guarded and rejects public keys, so the picker takes providers and renders them; it issues no API calls.
// Server component — fetch the list with the secret key, pass it down.
const { providers } = await relipay.billing.getProviders(country); // country optional (ISO-3166 alpha-2)
<PricingTable
plans={plans} // billing.getPlans() server-side
providers={providers} // billing.getProviders() server-side
checkoutAction={checkoutAction}
/>With providers passed, <PricingTable> renders the picker above the grid and threads the chosen provider into every plan's checkout form (posted as provider). Your checkout Server Action must forward it to createCheckout:
// lib/actions.ts
'use server';
export async function checkoutAction(formData: FormData) {
const planSlug = String(formData.get('planSlug'));
const provider = formData.get('provider'); // 'stripe' | 'paypal' | 'razorpay' | null
const { url } = await relipay.billing.createCheckout(session.accessToken, {
planSlug,
successUrl, cancelUrl,
...(provider ? { provider: provider as BillingProvider } : {}), // omit → geo router picks
});
redirect(url);
}Without
providers,<PricingTable>stays a Server Component (zero client JS). The provider-aware variant is an interactive client component, dispatched only whenprovidersis non-empty.
Use it standalone (outside <PricingTable>) by dropping it into any checkout <form> — the selected radio posts provider with no JS in uncontrolled mode:
<form action={checkoutAction}>
<input type="hidden" name="planSlug" value="pro_monthly" />
<ProviderPicker providers={providers} />
<button type="submit">Continue</button>
</form>It also supports a controlled value + onChange pair, an optional label, and a custom field name. The first provider in the list (the geo router's top pick) is selected by default.
Org-billing (billingSubject='org')
When your Application bills per team (Panel → Application → Billing → Subject = org), an individual can't hold a subscription — the user must be inside a team first, and the org id must ride along to checkout.
<OrganizationSwitcher billingSubject="org">hides the personal option and nudges the user to select/create a team when none is active.<PricingTable orgGateBlocking={...}>renders a "team required" gate instead of dead upgrade buttons.- Thread the active org into checkout with
hiddenFields={{ orgId }}(your action passes it asorganizationId, scoping the subscription to the team's shared pool).
Resolve billingSubject server-side from relipay.applications.me().billingConfig.billingSubject.
Theming
Every component accepts an appearance prop and a className. Theming is tokens-based — a single stylesheet keyed on CSS custom properties (--relipay-*), injected once and scoped under .relipay-root, so it never leaks into your app and depends on no CSS framework.
Light / dark
Defaults follow prefers-color-scheme. Pin it explicitly:
<SignIn appearance="dark" action={signInAction} />Override tokens
<SignIn
appearance={{
baseTheme: 'light',
variables: {
colorPrimary: '#6d28d9', // brand purple
colorBackground: '#faf5ff',
borderRadius: '8px',
fontFamily: 'Inter, sans-serif',
},
}}
action={signInAction}
/>| Variable | What it controls |
|---|---|
colorPrimary / colorPrimaryText |
brand colour + button label colour |
colorBackground / colorSurface |
page / card backgrounds |
colorText / colorTextMuted |
text colours |
colorBorder |
borders + dividers |
colorDanger |
destructive actions, errors |
borderRadius, fontFamily, fontSize, spacing |
shape + typography |
You can also set the --relipay-* variables in your own CSS for app-wide theming.
Per-element classes
Target one slot without re-theming (the appearance.elements pattern):
<SignIn
appearance={{ elements: { buttonPrimary: 'my-cta', card: 'shadow-2xl' } }}
action={signInAction}
/>Slots: root, card, header, title, subtitle, label, input, button, buttonPrimary, buttonSecondary, buttonDanger, divider, footer, avatar, menu, menuItem, badge, alert, planCard, price.
Full example
See examples/nextjs-saas for a complete Next.js 15 app wiring every component — including the /kitchen-sink page (a live gallery) — against a real ReliPay Application with org-scoped billing.
Headless escape hatch
Need full control? Skip the components and use useUser() + the control primitives, or talk to the API directly with RelipayBrowserClient (user-token-scoped reads only). The components are built on exactly these.
License
MIT