Usage-based billing in React Router (Remix): meter in the action, never the loader
Usage-based billing in React Router (Remix): record usage in the action, not the loader, and key each form render so a retried submit bills once.
In short
In a React Router (Remix) app, record usage in the action that does the work, never in a loader: loaders re-run after every successful submission and on link prefetch. Generate the idempotency key in the loader and send it as a hidden field, so a retry bills once and the next report gets a fresh key.
A React Router loader runs far more often than the page it belongs to is
opened. It runs again after every successful action on the page. It runs when someone
hovers a <Link prefetch="intent"> that points at it. Put a usage meter in
there and you are billing customers for looking.
The meter belongs in the action that does the work. The half that is easy to miss is the idempotency key, and it belongs in the loader. That sounds backwards until you see what it buys: a loader key survives a failed submission and rotates on a successful one.
The example is a workspace that pays per generated report: a reports quota,
one unit per report, a button that spends it and a counter that shows what is
left.
Before you start
A React Router v7 or v8 app in framework mode (or Remix v2 with
future.v3_singleFetch on) with sign-in already working through the React
Router setup guide, which gives you
buildbaseFor() and a buildbaseSessionId in your cookie session. In the
console, a plan with a reports quota on it and a workspace subscribed to
that plan. Workspace IDs in the URL, as in multi-tenant workspaces in
Remix.
1. Get a session-bound client from the request
The setup guide binds a client to one session with withSession(). Add a
helper that pulls the session out of the cookie, so every loader and action
starts from one line:
// app/utils/buildbase.server.ts (next to buildbaseFor from the setup guide)
import { authSessionStorage } from '#app/utils/session.server.ts';
import { redirect } from 'react-router';
export async function requireBuildBase(request: Request) {
const authSession = await authSessionStorage.getSession(
request.headers.get('cookie')
);
const sessionId = authSession.get('buildbaseSessionId');
if (!sessionId) throw redirect('/login');
return buildbaseFor(sessionId);
}Nothing to see yet. Signed out, any route that calls it sends you to /login.
2. The loader reads the quota, and hands out a key
// app/routes/w.$workspaceId.reports.tsx
import { randomUUID } from 'node:crypto';
import { requireBuildBase } from '#app/utils/buildbase.server.ts';
import type { Route } from './+types/w.$workspaceId.reports';
export async function loader({ request, params }: Route.LoaderArgs) {
const bb = await requireBuildBase(request);
const quota = await bb.usage.getQuota(params.workspaceId, 'reports');
return { quota, idempotencyKey: randomUUID() };
}getQuota returns consumed, included, available, overage and
hasOverage for that one quota. It reads. It never writes, which is the whole
reason it is allowed to live here: this loader will run on hover, on every
revalidation, and on every navigation back to the page, and none of those
should cost anyone anything.
The key is the part that looks odd. Why would a read hand out a write's idempotency key? Hold that thought until step 5.
Load /w/<workspaceId>/reports and you should see the loader data with a fresh
UUID in it on every reload.
3. The action records the usage, then does the work
// app/routes/w.$workspaceId.reports.tsx (continued)
import { data } from 'react-router';
export async function action({ request, params }: Route.ActionArgs) {
const bb = await requireBuildBase(request);
const form = await request.formData();
const key = form.get('idempotencyKey');
if (typeof key !== 'string' || !key || key.length > 64) {
return data({ error: 'Reload the page and try again.' }, { status: 400 });
}
try {
const usage = await bb.usage.record(params.workspaceId, {
quotaSlug: 'reports',
quantity: 1,
idempotencyKey: `report:${key}`,
});
const report = await generateReport(params.workspaceId);
return { report, usage };
} catch (err) {
const status = (err as { status?: number }).status;
const message = err instanceof Error ? err.message : '';
if (status === 400 && message.startsWith('Quota limit reached')) {
return data(
{ error: 'This workspace has used every report on its plan.' },
{ status: 402 }
);
}
if (status === 403) {
return data(
{ error: 'Your role cannot generate reports.' },
{ status: 403 }
);
}
throw err;
}
}Three things in there are not obvious.
The record comes first. Our per-API-call guide
records after the work succeeds, and on a plan that allows overage that is the
right order, because record never refuses for being over quota: past the
included amount it counts the units as overage. On a plan with a hard cap, or during a trial, record is
the gate. Call it after the work and the refusal arrives once the report is
already built and handed over.
Recording first has its own cost. A report that then fails has still been
counted, and bb.usage has no reversal. That trade-off, and how to pick per route, is the subject of usage metering
middleware in Express.
Over quota is a 400, not a 429. The server answers an over-quota record with
HTTP 400 and a message that starts Quota limit reached for 'reports'. What
the SDK throws is a plain Error with the status attached as status, a field
its type definitions leave out - hence the cast. Returning 402 is our choice,
for our own response. A 429 from the usage API only ever comes from its rate
limiter, which is a different problem with a different fix.
Some members get a 403. Recording usage needs the
workspace:usage:record permission, and the built-in viewer role does not
have it. It lacks usage view as well, so getQuota in the loader throws
for a viewer too. Decide whether viewers should see this page at all before you
ship it.
4. The form sends the key, and locks while it is in flight
// app/routes/w.$workspaceId.reports.tsx (continued)
import { useFetcher } from 'react-router';
export default function Reports({ loaderData }: Route.ComponentProps) {
const { quota, idempotencyKey } = loaderData;
const fetcher = useFetcher<typeof action>();
const busy = fetcher.state !== 'idle';
return (
<section>
<p>
{quota.consumed} of {quota.included} reports used this period
{quota.hasOverage && `, ${quota.overage} billed as overage`}
</p>
<fetcher.Form method="post">
<input type="hidden" name="idempotencyKey" value={idempotencyKey} />
<button type="submit" disabled={busy}>
{busy ? 'Generating...' : 'Generate report'}
</button>
</fetcher.Form>
{fetcher.data && 'error' in fetcher.data && (
<p role="alert">{fetcher.data.error}</p>
)}
</section>
);
}Click once. The button locks, the report comes back, and the count goes up by one without you writing any code to refresh it: a successful action revalidates every loader on the page. That revalidation also runs step 2's loader again, which is where the new key comes from.
The disabled is not decoration. Step 6 says why.
5. Why the key comes from the loader
A loader key holds while a submission fails and rotates when one succeeds, which is the rule billing needs. The other two places a key could come from both bill wrong.
Made in the action. Every attempt gets a new key, so nothing is ever deduplicated. A retry after a network blip bills twice.
Made in the component, with useState(() => crypto.randomUUID()) or a
ref. Now the key never changes. The first report is billed. Every report after
it replays the same key, and the server answers a replay with the current
status and used: 0. The customer generates reports all month and is billed
for one. Nothing errors. This is the version to be afraid of, because it
looks fine until someone reads an invoice.
Made in the loader. The key lives as long as one render of the page's data. React Router revalidates loaders after a successful submission, so a success rotates it and the next report is billed. It does not revalidate after a 4xx or 5xx by default, so a failed attempt keeps its key, and trying again sends the same one. A retry of a report that was already recorded comes back as a replay instead of a second charge.
That is the whole trick. The framework already draws the line between "that worked, the next one is new" and "that failed, this is the same attempt", and the loader is where that line is visible.
It is not perfect. The loader also runs on a reload, a navigation back and any other revalidation on the page, and each of those hands out a new key. So a customer whose connection dropped after the server recorded the report, and who then reloads and clicks again, sends a new key and is billed again. That is a new attempt as far as anything can tell, and we would rather name the gap than pretend the key closes it.
The key is matched on workspace, quota and key, and remembered for as long as
the usage log is kept: 730 days. Prefix it (report:) so a key from another form can
never collide with this one.
6. Why the button still has to lock
The key does not fully cover two submissions that arrive together, and a double-click produces exactly that. React Router's own concurrency docs say it plainly: cancelling a request in the browser "simply releases browser resources for that request; it can't 'catch up' and stop it from getting to the server." Click twice fast and both submissions reach your action, carrying the same key.
Why doesn't the key catch the second one? The server checks for an existing key and then increments the counter as separate steps, so two concurrent requests with the same key can both pass the check. A retry that arrives after the first one finished is handled. Two at once are not, and we would rather say so than let the key look like a guarantee it is not.
Locking the button while fetcher.state !== 'idle' removes the common case
(the double-click) on the client. The key covers the retries that get past it.
Neither is enough alone.
What breaks, and how you can tell
Usage climbs when nobody generates anything. A meter is in a loader, or in
something a loader calls. Search every loader for usage.record. Prefetch
and revalidation make this worse the more links point at the page.
Customers are billed for one report a month. The key is held in component
state or a ref and never changes. Log usage.used from the action: a replay
returns 0, a real record returns the quantity you sent.
One click, two charges. Two submissions ran at the same time with the same key. Usually the button is not locked, or two fetchers on the page post to the same action.
The counter does not move after "This workspace has used every report".
That is the default working as intended. A 4xx from the action skips
revalidation, so the loader does not re-run on refusal. The message comes from
fetcher.data, not the loader.
Too many usage recording requests. Please slow down. That is the 429 from
the usage API's rate limiter: 60 requests a minute, counted per IP. Every
request comes from your app server, so all your users share one budget. It has
nothing to do with the quota. Read the 429 that is not your
quota before raising any limit.
A viewer sees an error page instead of the counter. getQuota needs usage
view, which viewer lacks. Catch the 403 in the loader, or keep viewers off
the route.
Install
npm i @buildbase/sdk