Skip to content

The rules every Bridge guide builds on, on one page: what the smallest integration is, where a plan limit is counted, the three ways to show a limit in the UI, and the four levels of customising Bridge’s pages. Coding agents get the same page from bridge guide mechanisms.

The frontend examples are React (@nebulr-group/bridge-react); the backend examples are NestJS (@nebulr-group/bridge-nestjs).

Who gets which feature, and how plans, roles, limits and flags divide the work, is on its own page: bridge guide fit-together. In short: every gate in app code is a flag, and its rule says why (a privilege, a plan feature, a rollout).

What a developer’s repo holds once Bridge is in. Nothing else is required; every other page Bridge needs, it serves.

Frontend (React + React Router) — about ten lines in two files, plus one line of .env:

# .env
VITE_BRIDGE_APP_ID=your-app-id
// src/main.tsx
import { BridgeProvider } from '@nebulr-group/bridge-react';
import '@nebulr-group/bridge-react/styles';

createRoot(document.getElementById('root')!).render(
  <BridgeProvider config={{ loginRoute: '/auth/login' }}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </BridgeProvider>
);
// src/App.tsx — every sign-in page, and (with plans) every subscription page
import { BridgeAuthRoutes, BridgeBillingRoutes } from '@nebulr-group/bridge-react/react-router';

<Routes>
  <Route path="/auth/*" element={<BridgeAuthRoutes />} />
  <Route path="/subscription/*" element={<BridgeBillingRoutes />} />
  {/* …your own routes */}
</Routes>

/subscription/* serves the subscription page, the paywall and both checkout return pages. Leave it out if the app has no plans.

TanStack Router. The same two components come from @nebulr-group/bridge-react/tanstack-router, mounted on splat routes:

import { BridgeAuthRoutes, BridgeBillingRoutes } from '@nebulr-group/bridge-react/tanstack-router';

const authRoute = createRoute({ getParentRoute: () => rootRoute, path: 'auth/$', component: BridgeAuthRoutes });
const subscriptionRoute = createRoute({ getParentRoute: () => rootRoute, path: 'subscription/$', component: BridgeBillingRoutes });

With file-based routing: export const Route = createFileRoute('/auth/$')({ component: BridgeAuthRoutes }) in src/routes/auth/$.tsx.

No router. The main entry’s <BridgeAuthRoutes base="/auth" /> and <BridgeBillingRoutes base="/subscription" /> read window.location below base and navigate with full page loads. Render them wherever your app handles those paths.

Both routers are optional peer dependencies (react-router 6.4 to 7, @tanstack/react-router 1): only the entry you import needs its router installed. The adapter components also register the router as Bridge’s navigation, so the route guard, the OAuth callback and the paywall redirect move in-app. An app that renders neither component calls useBridgeRouter() (same entries) once inside the router.

Backend (NestJS) — 4 lines, plus one line of environment. The lines marked // + are Bridge’s; the rest is what nest new already gave you:

# .env
BRIDGE_APP_ID=your-app-id
// src/app.module.ts
import { Module } from '@nestjs/common';
import { BridgeModule } from '@nebulr-group/bridge-nestjs';                  // +
import { ExportsController } from './exports.controller';

@Module({
  imports: [BridgeModule.forRoot({ guard: { global: true } })],            // + every route needs a signed-in user
  controllers: [ExportsController],
})
export class AppModule {}
// src/exports.controller.ts
import { Controller, Post } from '@nestjs/common';
import { RequireQuota } from '@nebulr-group/bridge-nestjs';                  // +

@Controller('exports')
export class ExportsController {
  @Post()
  @RequireQuota('exports')                                                  // + the plan limit
  create() {
    return { exported: true };
  }
}

BRIDGE_APP_ID must be in the process environment. NestJS does not read .env by itself: start with node --env-file=.env dist/main.js, or set it in your container or process manager.

Settings are read for you. Each field resolves as explicit option > environment > default; an empty value counts as unset. An option passed in <BridgeProvider config> always wins over .env.

Frontend (Vite)Frontend (Create React App)BackendWhen to set it
VITE_BRIDGE_APP_IDREACT_APP_BRIDGE_APP_IDBRIDGE_APP_IDAlways. Missing, Bridge refuses to start and names the variable
VITE_BRIDGE_API_BASE_URLREACT_APP_BRIDGE_API_BASE_URLBRIDGE_API_BASE_URLOnly for a non-production app (stage, local, self-hosted). Unset means production
VITE_BRIDGE_HOSTED_URLREACT_APP_BRIDGE_HOSTED_URL—Only for a local or self-hosted Bridge. On Bridge’s own domains it follows the API address (api-stage → auth-stage)
VITE_BRIDGE_DEBUGREACT_APP_BRIDGE_DEBUGBRIDGE_DEBUGtrue for console logging

VITE_BRIDGE_CALLBACK_URL, VITE_BRIDGE_DEFAULT_REDIRECT_ROUTE and VITE_BRIDGE_LOGIN_ROUTE (and their REACT_APP_ twins) are read too. resolveBridgeConfig() is the same resolution as a function, for tests and tooling.

Ask one question first: does this action call your server?

  • It calls your backend: the backend handler counts it and refuses at the limit, because that is where the action happens. The frontend shows the count and the upgrade dialog, and does not count the same metric again.
  • It happens in the browser and never reaches a server of yours (local-first, data on the device): the browser counts it and <QuotaGate> stops the button at the limit (section 5). That is a complete, first-class setup.

Never both for one metric: it would be counted twice.

The backend decorator is the increment. There is nothing else to wire: no “report usage” call after the insert, no counter in your database, no endpoint for the frontend.

@Post()
@RequireQuota('exports')
create() { /* your code */ }
  • Before the handler: at the plan’s limit the request is refused with 402 and a body the frontend understands ({ code: 'QUOTA_EXCEEDED', metric, used, limit, fix }). Your handler never runs.
  • After the handler: only when it answered 2xx, Bridge records the use — exactly one write. A handler that throws or answers 4xx/5xx records nothing.
  • Retries: a request that sends an Idempotency-Key header is counted once per key, however often the client retries.
  • Fail-closed: if Bridge cannot answer, the request is refused with 503, never let through unchecked.
  • A metered quota never refuses: past its allowance it bills per unit.

If deleting it frees room, it’s a gauge and your app counts it; if it happened, it’s a counter and Bridge counts it.

CounterGauge
Examplesexports, API calls, AI completions, emails senttickets, projects, stored files, seats
Who countsBridge, one event per successful requestYour app — you pass your own count
Resetsevery billing period (“40 of 100 this month”)never (“8 of 10 projects”)
Configurebridge plan quota set <plan> --metric exports --limit 100 --policy hard (counter is the default)bridge plan quota set <plan> --metric tickets --limit 10 --policy hard --kind gauge
Backend@RequireQuota('exports')@RequireQuota('tickets', { current }) on create, @SyncQuota('tickets', { current }) on delete
@Post()
@RequireQuota('tickets', { current: (t, self: TicketsController) => self.tickets.countFor(t.id) })
create() { /* … */ }

@Delete(':id')
@SyncQuota('tickets', { current: (t, self: TicketsController) => self.tickets.countFor(t.id) })
remove() { /* … */ }

current returns your own count, and that count is what the limit is compared against, so Bridge’s copy heals itself if it ever missed an update. There is no decrement and no reservation: every create and delete sends the whole count.

Seats are a gauge the app names (for example seats) and marks as counted from membership: bridge plan quota set <plan> --metric seats --limit N --policy hard --kind gauge --source membership. Bridge answers it with the workspace’s members, pending invites included, so nothing reports it. The check runs where invites start: seatsMetric="seats" on the built-in team page, or @RequireQuota('seats') on your own invite handler, which checks the seat limit and writes nothing. Bridge’s invite API does not refuse at the limit.

bridge plan quota list shows every metric the app’s plans limit, with its kind.

Every hard quota is also an entitlement with the same name, true while there is room. So never pair @RequireEntitlement('exports') with @RequireQuota('exports'): at the cap the entitlement answers 403 before the quota can answer the 402 the frontend knows how to upsell. @RequireQuota is for a limit; a plan feature (analytics, sso) is a flag (next paragraph).

A plan feature goes in the plan’s features list. bridge plan feature add pro analytics --name "Analytics" makes analytics true on pro, and false on every other plan. The pricing table and the upgrade dialog name it from the same list. Control the feature with a flag whose rule is bridge:billing.entitlement.analytics eq true — <FeatureFlag flagKey="analytics"> in the UI, @RequireFeatureFlag('analytics') on the backend — so changing what Pro sells is one edit on the plan and no flag rule has to follow. app_active is always there: true while the workspace’s subscription is active, trialing, past due or cancelling at period end.

Pick the lowest level that does the job. Each is optional; level 0 is on without code.

LevelWhat the page writesWhat the user sees
0 — nothinga button calling your API with bridgeFetch() (or plain fetch to your own origin)Your backend refuses at the cap (402), and <BridgeProvider> opens an upgrade dialog naming the metric, linking to the subscription page. A workspace member who cannot manage billing is told to ask the owner instead
1 — one component<QuotaGate metric="tickets">…</QuotaGate> around the button; <FeatureFlag flagKey="analytics" defaultValue={false} upgrade>…</FeatureFlag> around a paid featureThe button is disabled at a known hard cap with an upgrade line beside it; the paid feature shows on a plan that includes it, and elsewhere an “Upgrade to use this” button that opens the dialog when clicked
2 — your own UIuseQuota('tickets'), and <FeatureFlag flagKey="analytics"> around what the plan sellsWhatever you build from the live numbers
import { QuotaGate, FeatureFlag, useQuota } from '@nebulr-group/bridge-react';

// level 1
<QuotaGate metric="tickets">
  <button onClick={createTicket}>New ticket</button>
</QuotaGate>

<QuotaGate metric="tickets" atLimit={(q) => <>{q.used} of {q.limit} tickets used. <a href="/subscription">Upgrade</a></>}>
  <button onClick={createTicket}>New ticket</button>
</QuotaGate>

{/* the flag's rule: bridge:billing.entitlement.analytics eq true */}
<FeatureFlag flagKey="analytics" defaultValue={false} upgrade>
  <a href="/analytics">Analytics</a>
</FeatureFlag>

// level 2
function TicketCount() {
  const tickets = useQuota('tickets');
  if (tickets.loading) return <>Loading…</>;
  if (tickets.unlimited) return <>Unlimited tickets</>;
  return <>{tickets.used} of {tickets.limit} tickets</>;
}

useQuota(metric) returns { loading, unlimited, used, limit, remaining, warningLevel, kind, snapshot }, and moves on its own when usage changes.

  • “Not loaded yet” is never “zero” and never “not allowed”: useQuota numbers stay null while loading, and <QuotaGate> stays enabled while loading.
  • No upgrade dialog opens by itself: it opens on a 402 from your backend, or when someone clicks an upgrade prompt (the upgrade button, or openUpgrade from a <FeatureFlag> fallback). A page that only renders a hidden feature opens nothing.
  • bridgeFetch adds the signed-in user’s token; a plain fetch sends none, so a protected backend answers 401, never 402. The dialog opens from any 402 QUOTA_EXCEEDED (or 402 FEATURE_NOT_IN_PLAN) that bridgeFetch sees, on any origin, and from a plain fetch to the page’s own origin or Bridge’s API. A backend on another origin called with plain fetch is listed in billing.apiOrigins.
  • billing: { upgradeDialog: false } turns the dialog off (listen with onBridgeQuotaExceeded(handler) instead); billing: { upgradeDialog: MyDialog } replaces it. Your component receives { refusal, upgradeHref, canUpgrade, onClose, feature, plans }.
  • Do not write a quota if, a “limit reached” toast or a /quota endpoint of your own: level 0 already covers the refusal, and the frontend reads quota directly from Bridge.

5. Count usage once, where the action happens

Section titled “5. Count usage once, where the action happens”

Ask one question: does the click call your server?

  • Yes — the backend handler that does the work counts it (bridge-nestjs @RequireQuota / @SyncQuota). The page only shows the number and the upgrade dialog; it does not report anything.
  • No — the action happens in the browser (a local-first or mobile app, data on the device), so the browser counts it. This is a complete, first-class way to run limits:
import { bridge } from '@nebulr-group/bridge-react';

bridge.usage.report('exports');                        // a counter: it happened
await bridge.usage.set('projects', projects.length);   // a gauge: how many exist now

Put <QuotaGate metric="exports"> around the button (section 4, level 1) so it stops at the limit, and the upgrade line sells the next plan.

Never both: counting the same action on each side counts it twice. In development the plugin warns once in the console when your backend and the page count the same metric (bridge-nestjs marks a counting response with X-Bridge-Usage-Counted outside production; nothing is sent or printed in production).

6. Four levels of customising Bridge’s pages

Section titled “6. Four levels of customising Bridge’s pages”

The same ladder holds for the sign-in pages (<BridgeAuthRoutes>) and the subscription pages (<BridgeBillingRoutes>). Climb only as far as you need.

RungWhat you doWhat you own
0 — nothingBridge’s pages render inside your own layoutYour navigation, header and shell already surround them
1 — tokensSet --bridge-* CSS variables in your CSSColours, radius, spacing
2 — frame and headingPass the frame(page, children) and heading(page) render propsEverything around the form on every page, and each page’s heading
3 — take over one pagePass an element for that page: pages={{ login: <MyLogin /> }}That one page; every other page keeps working
4 — headlessBuild your own UI on getBridgeAuth()Everything
// rung 2
<Route
  path="/auth/*"
  element={
    <BridgeAuthRoutes
      frame={(page, children) => <main className="auth-card">{children}</main>}
      heading={(page) => <h1>{page === 'signup' ? 'Create your account' : 'Welcome back'}</h1>}
    />
  }
/>

// rung 3
<Route path="/auth/*" element={<BridgeAuthRoutes pages={{ login: <MyLoginPage /> }} />} />

heading replaces only each page’s main step (the login credentials step, the signup form, the set-password form); sub-steps such as “Check your email” keep their own, so two headings never stack. There are no per-page render props: to change more than frame and heading, take the page over (rung 3). A sign-in page you own navigates itself after sign-in: <LoginForm onLogin={() => navigate(readReturnTo(location.search) ?? '/')} />. <BridgeBillingRoutes> takes the same frame, heading and pages props (pages manage, plan, success, error). Both take notFound for an address under the mount point that names no page, and redirectTo (default /) for where a completed sign-in, or “Continue” on the success page, goes.

The token contract (rung 1). These --bridge-* variables are the supported way to restyle every Bridge page and component. Set them in your own stylesheet on :root, or on a wrapper to theme one area. The plugin declares its defaults on :where(:root), which has zero specificity, so your :root wins whichever stylesheet loads first.

TokenDefaultStyles
--bridge-primary#4f46e5Primary buttons, the selected tab and plan interval, the active workspace
--bridge-primary-hoverprimary, 15% darkerPrimary button hover
--bridge-primary-fg#ffffffText on primary surfaces
--bridge-primary-lightprimary at 10%Tint behind the active workspace
--bridge-input-focusprimaryInput focus ring
--bridge-bg#ffffffDialogs, menus, panels
--bridge-foreground#111827Text on those surfaces, workspace names
--bridge-muted#6b7280Secondary text, hints, table headings
--bridge-muted-bg#f3f4f6Subtle fills: tab tracks, hovers, the MFA backup code
--bridge-border#d1d5dbInput, table, card and secondary button borders
--bridge-border-radius6pxCorners of inputs, buttons, alerts, cards and dialogs
--bridge-overlayrgba(15, 23, 42, 0.45)Backdrop behind dialogs
--bridge-alert-error-{bg,fg,border}#fef2f2 / #991b1b / #fca5a5Errors, critical billing notices, cancelled plan badge
--bridge-alert-success-{bg,fg,border}#f0fdf4 / #166534 / #86efacConfirmations, active plan badge
--bridge-alert-info-{bg,fg,border}#eff6ff / #1e40af / #bfdbfeInfo notices, trial badge
--bridge-alert-warning-{bg,fg,border}#fffbeb / #92400e / #fcd34dWarnings, a quota nearing its limit, past-due badge
--bridge-auth-page-padding3rem 1remPadding of the default sign-in page container
--bridge-billing-page-width60remWidth of the subscription pages and <BridgePaywallPage>
--bridge-billing-page-padding3rem 1remPadding of the subscription pages and <BridgePaywallPage>
--bridge-paywall-bgrgba(15, 23, 42, 0.72)Backdrop of the <BridgePaywall> overlay and the billing lockscreen
--bridge-paywall-panel-bg--bridge-bgPanel of the <BridgePaywall> overlay

The derived tokens (hover, light, focus) follow --bridge-primary wherever you set it, unless you set them too. Two older names still work as deprecated aliases: --bridge-primary-foreground for --bridge-primary-fg, and --bridge-bg-muted for --bridge-muted-bg. Fonts and body text colour are not tokens: Bridge pages render inside your layout and inherit them. With Tailwind, point the tokens at your theme (see theming). Leaving out import '@nebulr-group/bridge-react/styles' makes the components headless: plain HTML for you to style.

7. Pages Bridge serves, and the one it only offers

Section titled “7. Pages Bridge serves, and the one it only offers”
  • Sign-in: <BridgeAuthRoutes> on /auth/* serves login, signup, the OAuth callback, set password (where signup verification and password-reset emails land), forgot password, magic link, passkey setup and workspace selection. Which methods appear comes from the app’s settings at runtime, so turning magic links on needs no code. Hosted and in-app sign-in use the same route; loginRoute: '/auth/login' in <BridgeProvider config> is the whole switch to in-app. Without it the pages point at Bridge’s hosted login.
  • Subscription: <BridgeBillingRoutes> on /subscription/* serves /subscription, the paywall /subscription/plan, and the checkout return pages /subscription/success and /subscription/error. Bridge’s redirects and upgrade links point there by default (billing.manageRoute, billing.paywallRoute, billing.paymentErrorRoute).
  • The paywall redirects a signed-in workspace with no plan to /subscription/plan — only when the app has plans and its paymentsAutoRedirect setting is on (the default). billing: { paywallRoute: false } turns the redirect off; bridge app update --payments-auto-redirect false turns it off for the app.
  • A /welcome onboarding page is offered, never created unasked. It is a product decision: ask the developer. Only if they say yes, add a route rendering <BridgePaywallPage /> and billing: { paywallRoute: '/welcome' } in <BridgeProvider config>.
  • Any other address under a mount point gets the notFound element (a short “Page not found.” by default).

Checking a plan feature without a flag (@RequireEntitlement on the backend, <Entitled to="analytics"> or useEntitlements().can('analytics') in the UI) exists for the rare case where the developer explicitly asks for no flag. It prints a one-time note in development; mark the line // bridge-gate-exception: <reason> so bridge check gates leaves it.