The browser side of Mesub, recurring payments on Solana: a Subscribe button and a list of subscriptions that handle the wallet for you.
It is a complement to @mesub/node,
which does everything on its own. This package only spares you the wallet
part in the browser: picking one, connecting it, signing in the right order.
- It never talks to Mesub and holds no key. It calls your own server,
on the routes
@mesub/nodemounts there. - Your customer has no Mesub account. Who they are comes from your own login: there is no sign-in here, no session, no token.
- It is not a wallet connector for your site. It asks a wallet for an account when it needs a signature, and never disconnects one.
Read the docs for the walkthrough.
Status: early, 0.x, not on npm yet. The API may still change between minor versions. See the board for what is next.
React 18 or 19. Works with Next.js's App Router: the build starts with
'use client'. Nothing Solana to install on your side.
On your server, after your own login (details):
import { mesubRoutes } from '@mesub/node/express';
app.use(
'/api/mesub',
mesubRoutes({ customer: (req) => (req.user ? { external_id: req.user.id } : null) }),
);In your app, once:
import { MesubProvider } from '@mesub/react';
import '@mesub/react/styles.css';
export function App({ children }) {
return <MesubProvider endpoint="/api/mesub">{children}</MesubProvider>;
}| Prop | What it is |
|---|---|
endpoint |
Where you mounted the routes. A path on your site, or a full URL. |
fetch |
Your own (url, init) => Promise<Response>, to add headers or credentials. |
theme |
light, dark or auto, set on the widget as data-mesub-theme. |
manageUrl |
Where "Cancel any time" leads once subscribed: your own page (/account, or a full URL). Mesub's page by default; null for no button. |
Requests go with credentials: 'include', so your session cookie travels. If
your login is a bearer token instead, add it in fetch:
<MesubProvider
endpoint="/api/mesub"
fetch={(url, init) =>
fetch(url, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}` } })
}
>import { SubscribeButton } from '@mesub/react';
<SubscribeButton plan="pro" onSubscribed={(subscription) => unlock(subscription)}>
Subscribe to Pro
</SubscribeButton>;plan is the plan's slug, as set in the Mesub dashboard. The click opens one
dialog, from the plan to the receipt:
- The plan: its name, price and period, from
GET /plans/:slug, and its end date when it has one. A plan that ends before a second charge can run is pricedonce, with the date its access runs to. - A wallet: the installed wallets that can sign (Wallet Standard, so Phantom, Solflare and the others). The one picked is asked for its account.
- The review: your server prepares the subscription
(
POST /subscriptions), and the dialog shows what it costs before the wallet is asked anything: the price, the next charge, the paying wallet, the deposit returned when the subscription is closed, the network fee, and the terms. From here to the receipt, the terms decide whether it is a single charge (Amount: 2 USDC, a single charge): no next charge is shown then, only the end of the access. - Two approvals: the wallet signs the terms, a message
(
solana:signMessage), then the transaction, without sending it (solana:signTransaction). A transaction lives about half a minute, so a fresh one is asked for once the terms are signed (POST /subscriptions/:id/transaction), and that one is signed. If it names new terms, they are shown and signed again. A server whose@mesub/nodepredates the route answers 404: the transaction of the review is signed. - Confirming: both go to
POST /subscriptions/:id/submit. Mesub co-signs and sends the transaction, andonSubscribedfires once it landed. A transaction that expired before it was sent (transaction_expired) is replaced once, for the same subscription, and signed again; a second expiry in a row stops with "Nothing was charged."
The button is a <button> with data-mesub-subscribe and data-mesub-state
(idle, open, signing, confirming or subscribed). children is its
label, every other button prop goes to the button, and an onClick that calls
preventDefault() stops the flow. Once subscribed it is followed by
<span data-mesub-receipt>.
For a button of your own, useSubscribe opens the same dialog:
import { useSubscribe } from '@mesub/react';
function Buy() {
const { state, subscribe } = useSubscribe('pro', { onSubscribed: unlock });
return (
<button disabled={state !== 'idle'} onClick={() => void subscribe()}>
{state === 'subscribed' ? 'Thanks!' : 'Buy'}
</button>
);
}subscribe() resolves when the dialog closes: with the subscription, or null
when it did not go through. The hook also gives subscription and
signature, the transaction's id when the wallet is its first signer.
import { ManageButton } from '@mesub/react';
<ManageButton plan="pro" />;A button, like SubscribeButton: it opens the customer's subscription to that
plan in the Mesub window. One subscription, not a list: how it stands, what is
charged next, the latest payments with their receipts, and the one thing it
allows now (cancel, resume or close). Leave plan out to open the customer's
live subscription whatever its plan. Pass children to word the button your way.
The payments and the next charge come from GET /subscriptions/:id of the
routes; on a server that predates it, the window stands without them.
To list every subscription in a page of yours instead:
import { ManageSubscriptions } from '@mesub/react';
<ManageSubscriptions onChanged={(subscription) => refresh(subscription)} />;The signed-in customer's subscriptions, from GET /subscriptions: each with
its plan, its status, its next charge or the end of its access, the wallet that
pays it, and the one thing it allows now. A subscription in the last period of
a plan that ends has no next charge: it shows the end of its access instead.
| The subscription is | It allows |
|---|---|
running (active, unpaid, stopped) |
Cancel |
cancelled, with access still running |
Resume |
over (ended, or cancelled and past its end) |
Close |
Each opens a dialog that says what it does, then runs two steps: your server
builds a transaction (POST /subscriptions/:id/cancel, /resume or /close),
the wallet signs and sends it itself (solana:signAndSendTransaction), and
its signature goes to the matching /confirm. The list is read again after.
A late one (unpaid) also shows Pay now when paying can succeed: the
customer pays the missed period at once, with nothing to sign, through
POST /subscriptions/:id/retry of the routes (subscriptions.retry of
@mesub/node). Not on a paused one, on a wallet that no longer approves it,
nor past its date to pay by. What came of it is said under the button: the
payment on its way, or why not (a short wallet spends no retry). On Free,
where Mesub does not try again on its own, the card shows that date to pay by.
One whose charges are held back for a cause that is not the customer's
(blocked_since) says so in one line: payments are on hold, nothing is asked
of them, and until when their access runs. The cause is never named to them,
and Pay now is not offered meanwhile.
Only the wallet that pays a subscription can sign for it. One already connected to your site is found without a prompt; otherwise the customer picks it. Closing returns the deposit to that wallet.
For a list of your own, useSubscriptions gives the same:
import { useSubscriptions } from '@mesub/react';
function Mine() {
const { state, subscriptions, error, reload, manage } = useSubscriptions();
if (state === 'signed-out') return <a href="/login">Sign in</a>;
return subscriptions.map((held) => (
<button key={held.id} disabled={!held.action} onClick={() => void manage(held.id)}>
{held.plan}: {held.status}, {held.action ?? 'nothing to do'}
</button>
));
}stateisloading,ready,signed-outorerror(witherror, the server's message).subscriptionsare newest first, each withaction:cancel,resume,closeor null, andpayable: whether Pay now can succeed. Checkouts nobody signed are left out.payNow(id)pays that late payment now and resolves{ ok, message }, the words to show; it never throws.manage(id)opens the dialog for that action and resolves when it closes: the subscription as it is now, or null if nothing changed.- Every list reads again when a subscription is made or changed through the
widget;
reload()does it on demand.
Fields are as @mesub/node serves them: snake case, dates as ISO strings.
Each one has its own screen, and says whether anything moved: "Nothing was charged." (or "Nothing changed.") only when nothing can have landed.
- Nobody signed in on your site (your routes answer 401): the dialog says to sign in first and offers nothing to sign; the list says to sign in.
- No wallet installed: links to Phantom and Solflare, and Reload.
- A wallet that cannot sign a message, or a transaction without sending it: left out of the list, and named with what it lacks.
- The wallet refuses to connect, to sign the terms or to sign the transaction: nothing is submitted. Try again asks your server anew and shows the review again.
- The terms expired (
expires_atpassed, before or between the two signatures): your server is asked again and the review comes back with fresh terms. Stale terms are never signed nor submitted. - The wrong wallet for a subscription being managed: it says which account the wallet is on and which one pays. Nothing is built or signed.
- Nothing landed (
reasonon submit or on a confirm): Mesub's reason, as written. - Mesub refuses (409: already subscribed, not enough to pay the first
period, plan closed...): its message, as written. A 403
wallet_mismatchasks for another wallet, a 429 says how long to wait. - The network fails or times out: 15 seconds on reads and builds, with Try again. Submit and the confirms wait 90 seconds for the chain; past that, or on a 5xx, the outcome is unknown: "Payment may be pending" and Check again, which sends the same request and never a second payment.
- Double clicks open one dialog, prepare one subscription and ask the wallet once.
@mesub/react/styles.css styles the widget only, through its data-mesub-*
attributes (no class names), and reads your --mesub-* custom properties
without ever setting them: set --mesub-accent, --mesub-bg, --mesub-text,
--mesub-font or --mesub-radius on :root to brand it. Dark mode is
data-mesub-theme="dark" on the widget or an ancestor, "auto" follows the
system, and <MesubProvider theme="dark"> sets it on the widget for you. Under
480px the dialog is a bottom sheet, and motion follows
prefers-reduced-motion. Leave the stylesheet out to style the attributes
yourself.
The dialog is a native <dialog>; data-mesub-step says where it is: plan,
wallet, review, approve, confirming, subscribed when subscribing, and
confirm, wallet, approve, confirming, done when managing. The list is
[data-mesub-subscriptions], with data-mesub-state, one
[data-mesub-subscription] per row, [data-mesub-status] and
[data-mesub-action].
pnpm install
pnpm test # in happy-dom, with Testing Library
pnpm typecheck
pnpm lint
pnpm build # dist/, ESM and CJS, with declaration files
pnpm check:exports # resolves through import and require, starts with 'use client'The pre-push hook runs all of it, as CI does. playground/
runs the widget against a local back, with a merchant server that mounts the
routes.