How Bridge works
Section titled “How Bridge works”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).
The whole integration
Section titled “The whole integration”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) | Backend | When to set it |
|---|---|---|---|
VITE_BRIDGE_APP_ID | REACT_APP_BRIDGE_APP_ID | BRIDGE_APP_ID | Always. Missing, Bridge refuses to start and names the variable |
VITE_BRIDGE_API_BASE_URL | REACT_APP_BRIDGE_API_BASE_URL | BRIDGE_API_BASE_URL | Only for a non-production app (stage, local, self-hosted). Unset means production |
VITE_BRIDGE_HOSTED_URL | REACT_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_DEBUG | REACT_APP_BRIDGE_DEBUG | BRIDGE_DEBUG | true 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.
1. Decide once, where the action happens
Section titled “1. Decide once, where the action happens”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.
2. A POST increments the limit
Section titled “2. A POST increments the limit”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
402and 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-Keyheader 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
meteredquota never refuses: past its allowance it bills per unit.
3. Counter or gauge
Section titled “3. Counter or gauge”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.
| Counter | Gauge | |
|---|---|---|
| Examples | exports, API calls, AI completions, emails sent | tickets, projects, stored files, seats |
| Who counts | Bridge, one event per successful request | Your app — you pass your own count |
| Resets | every billing period (“40 of 100 this month”) | never (“8 of 10 projects”) |
| Configure | bridge 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.
4. Three ways to handle a limit in the UI
Section titled “4. Three ways to handle a limit in the UI”Pick the lowest level that does the job. Each is optional; level 0 is on without code.
| Level | What the page writes | What the user sees |
|---|---|---|
| 0 — nothing | a 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 feature | The 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 UI | useQuota('tickets'), and <FeatureFlag flagKey="analytics"> around what the plan sells | Whatever 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”:
useQuotanumbers staynullwhileloading, and<QuotaGate>stays enabled while loading. - No upgrade dialog opens by itself: it opens on a
402from your backend, or when someone clicks an upgrade prompt (theupgradebutton, oropenUpgradefrom a<FeatureFlag>fallback). A page that only renders a hidden feature opens nothing. bridgeFetchadds the signed-in user’s token; a plainfetchsends none, so a protected backend answers401, never402. The dialog opens from any402 QUOTA_EXCEEDED(or402 FEATURE_NOT_IN_PLAN) thatbridgeFetchsees, on any origin, and from a plainfetchto the page’s own origin or Bridge’s API. A backend on another origin called with plainfetchis listed inbilling.apiOrigins.billing: { upgradeDialog: false }turns the dialog off (listen withonBridgeQuotaExceeded(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/quotaendpoint 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.
| Rung | What you do | What you own |
|---|---|---|
| 0 — nothing | Bridge’s pages render inside your own layout | Your navigation, header and shell already surround them |
| 1 — tokens | Set --bridge-* CSS variables in your CSS | Colours, radius, spacing |
| 2 — frame and heading | Pass the frame(page, children) and heading(page) render props | Everything around the form on every page, and each page’s heading |
| 3 — take over one page | Pass an element for that page: pages={{ login: <MyLogin /> }} | That one page; every other page keeps working |
| 4 — headless | Build 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.
| Token | Default | Styles |
|---|---|---|
--bridge-primary | #4f46e5 | Primary buttons, the selected tab and plan interval, the active workspace |
--bridge-primary-hover | primary, 15% darker | Primary button hover |
--bridge-primary-fg | #ffffff | Text on primary surfaces |
--bridge-primary-light | primary at 10% | Tint behind the active workspace |
--bridge-input-focus | primary | Input focus ring |
--bridge-bg | #ffffff | Dialogs, menus, panels |
--bridge-foreground | #111827 | Text on those surfaces, workspace names |
--bridge-muted | #6b7280 | Secondary text, hints, table headings |
--bridge-muted-bg | #f3f4f6 | Subtle fills: tab tracks, hovers, the MFA backup code |
--bridge-border | #d1d5db | Input, table, card and secondary button borders |
--bridge-border-radius | 6px | Corners of inputs, buttons, alerts, cards and dialogs |
--bridge-overlay | rgba(15, 23, 42, 0.45) | Backdrop behind dialogs |
--bridge-alert-error-{bg,fg,border} | #fef2f2 / #991b1b / #fca5a5 | Errors, critical billing notices, cancelled plan badge |
--bridge-alert-success-{bg,fg,border} | #f0fdf4 / #166534 / #86efac | Confirmations, active plan badge |
--bridge-alert-info-{bg,fg,border} | #eff6ff / #1e40af / #bfdbfe | Info notices, trial badge |
--bridge-alert-warning-{bg,fg,border} | #fffbeb / #92400e / #fcd34d | Warnings, a quota nearing its limit, past-due badge |
--bridge-auth-page-padding | 3rem 1rem | Padding of the default sign-in page container |
--bridge-billing-page-width | 60rem | Width of the subscription pages and <BridgePaywallPage> |
--bridge-billing-page-padding | 3rem 1rem | Padding of the subscription pages and <BridgePaywallPage> |
--bridge-paywall-bg | rgba(15, 23, 42, 0.72) | Backdrop of the <BridgePaywall> overlay and the billing lockscreen |
--bridge-paywall-panel-bg | --bridge-bg | Panel 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/successand/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 itspaymentsAutoRedirectsetting is on (the default).billing: { paywallRoute: false }turns the redirect off;bridge app update --payments-auto-redirect falseturns it off for the app. - A
/welcomeonboarding page is offered, never created unasked. It is a product decision: ask the developer. Only if they say yes, add a route rendering<BridgePaywallPage />andbilling: { paywallRoute: '/welcome' }in<BridgeProvider config>. - Any other address under a mount point gets the
notFoundelement (a short “Page not found.” by default).
Where the details live
Section titled “Where the details live”- Sign-in: hosted quickstart, in-app quickstart, route guards
- Plans and limits: how billing works, usage limits, lock features to a plan, report usage
- Styling: theming
- The backend: the bridge-nestjs docs, “Plan limits”
Exceptions
Section titled “Exceptions”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.