Guides
Build guide

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.

Dharmendra Jagodana7 min read

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
react-router
remix
billing

Frequently Asked Questions

Can I record usage in a React Router loader?

Not for billing. Loaders re-run after every successful action and when a Link with prefetch="intent" is hovered, so a meter in a loader counts page views and hovers rather than work. Record in the action or resource route that does the work.

Does cancelling a fetcher stop the charge?

No. React Router cancels the request in the browser, but its own docs say the request still reaches the server, so the action runs. The idempotency key is what stops a retried submission being billed twice.

Where should the idempotency key come from in a React Router form?

From the loader, sent as a hidden input. A key made inside the action changes on every attempt, and a key held in component state never changes, so every report after the first is swallowed as a replay. A loader key holds across failed attempts and rotates when one succeeds.

Is this different for Remix v2?

Only if single fetch is on. Remix v2 became React Router v7 framework mode, but plain Remix v2 revalidates every loader after any action, failed or not, so a failed attempt would get a new key. With future.v3_singleFetch on, it skips revalidation after a 4xx or 5xx like React Router does, and this page applies with the imports renamed.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime