Quota
/ docs
Dashboard
Docs/Next.js SDK

Next.js SDK

@usequota/nextjs is the React + App Router SDK. Drop-in sign-in button, balance pill, top-up flow, and route handlers — composed on top of @usequota/core.

01 Install

npm install @usequota/nextjs

Set three environment variables:

QUOTA_CLIENT_ID=quota_client_...
QUOTA_CLIENT_SECRET=quota_secret_...
NEXT_PUBLIC_QUOTA_CLIENT_ID=quota_client_...   # same as QUOTA_CLIENT_ID
# Optional — point at staging/local
# NEXT_PUBLIC_QUOTA_API_URL=http://localhost:3000

02 Wire it up (2 files)

Mount the route handlers and wrap your app in the provider. The route handlers own the OAuth callback end-to-end — no separate middleware needed.

2.1 Route handlers

createQuotaRouteHandlers returns one function per API route your app needs. Define them once in lib/quota.ts, then re-export from each route file.

import { createQuotaRouteHandlers } from "@usequota/nextjs/server";

// Destructure the named exports so each route file can re-export the
// one it needs (e.g. `export { authorize as GET } from "@/lib/quota"`).
export const {
  authorize,
  callback,
  me,
  logout,
  status,
  packages,
  checkout,
  disconnect,
} = createQuotaRouteHandlers({
  clientId: process.env.QUOTA_CLIENT_ID!,
  clientSecret: process.env.QUOTA_CLIENT_SECRET!,
});

2.2 Provider

import { QuotaProvider } from "@usequota/nextjs";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <QuotaProvider clientId={process.env.NEXT_PUBLIC_QUOTA_CLIENT_ID!}>
          {children}
        </QuotaProvider>
      </body>
    </html>
  );
}

03 Components

Pre-built components mount inside the provider tree. Each has a canonical example below; see the ParamsTable for full prop reference. Pick <QuotaSignInButton /> when Quota IS your auth provider (IdP pattern); pick <QuotaConnectButton /> when you already have auth and just want to link a wallet.

<QuotaSignInButton />

The branded “Sign in with Quota” button. Default scopes: openid email profile credits.spend. Clicking starts the OAuth flow via useQuota().login(). Set provider="google" or "github" for social-login variants (Quota brokers the round-trip server-side).

import { QuotaSignInButton } from "@usequota/nextjs";

export function Header() {
  return <QuotaSignInButton variant="filled" />;
}
variant"filled" | "outlined" | "ghost"Visual style for the default button. Defaults to "filled". Provider-specific buttons follow the provider brand guidelines and ignore this prop.
provider"google" | "github"Render the social-login variant. Only shows when the operator has configured the provider on the Quota side.
theme"light" | "dark" | "auto"Defaults to "auto" (respects prefers-color-scheme).
hideLogobooleanHide the leading Q / provider logo. Defaults to false.
childrenReactNodeOverride the default copy. Default: "Sign in with Quota".

<QuotaBalance />

A balance pill safe to drop into any header. Renders nothing while the provider is loading or when the user is signed out.

import { QuotaBalance } from "@usequota/nextjs";

<QuotaBalance format="dollars" showRefresh />
format"credits" | "dollars"Display unit. "dollars" divides by 1_000_000 and formats as USD. Defaults to "credits".
showIconbooleanShow the leading icon. Defaults to true.
showRefreshbooleanRender a refresh button next to the balance. Defaults to false.
onClick() => voidWhen set, renders the pill as a button. Useful for linking to a top-up page.
ariaLabelstringAccessible label. Defaults to "Credit balance".

<QuotaBuyCredits />

A button that opens a Stripe checkout in a new tab. Three modes: omit both packageId and amount for an inline package picker; pass amount (in credits) to auto-pick the smallest package that covers it; pass packageId to skip the picker entirely. After the checkout window closes the component compares balance against a pre-open snapshot — onSuccess fires on increase, onCancel on no change, onCheckoutClosed on either.

import { QuotaBuyCredits } from "@usequota/nextjs";

export function TopUp() {
  return (
    <QuotaBuyCredits
      packageId="starter"
      onSuccess={() => toast.success("Credits added")}
      onCancel={() => toast.info("Checkout closed — no charge")}
      onError={(err) => console.error(err)}
    >
      Top up — Starter
    </QuotaBuyCredits>
  );
}
packageIdstringSpecific package id — one of starter, basic, plus, pro (or whatever /v1/packages returns). Skips the picker.
amountnumberDesired credit amount. The SDK picks the smallest package whose credits >= amount. Ignored when packageId is provided.
checkoutPathstringPath to your checkout API route. Defaults to /api/quota/checkout.
packagesPathstringPath to your packages API route. Defaults to /api/quota/packages.
classNamestringClass names for the wrapper div (root element). Use this for layout.
buttonClassNamestringClass names for the inner button.
variant"primary" | "secondary" | "ghost"Defaults to "primary".
onSuccess() => voidFires after a verified balance increase post-checkout.
onCheckoutClosed() => voidFires whenever the checkout window closes, regardless of outcome.
onCancel() => voidFires when the window closed but balance did not change.
onError(error: Error) => voidFires if the checkout API call or package fetch fails.

<QuotaUserMenu />

A dropdown showing avatar, balance, and an actions list (top up, disconnect). Keyboard navigation and ARIA out of the box.

import { QuotaUserMenu } from "@usequota/nextjs";

<QuotaUserMenu
  showBuyCredits
  onBuyCredits={() => router.push("/billing")}
  onSuccess={() => router.push("/")}
  onError={(err) => toast.error(err.message)}
/>
showBuyCreditsbooleanWhether to show the 'Buy Credits' item. Defaults to true.
onBuyCredits() => voidFires when the user clicks 'Buy Credits'. Wire to navigation or a modal.
onSuccess() => voidFires after logout() resolves successfully.
onError(err: Error) => voidFires when logout() rejects. Without it the error is swallowed silently.
endQuotaSessionbooleanWhen true, also ends the Quota IdP session via OIDC RP-initiated logout. Defaults to false (local-only logout).
classNamestringClass names appended to the root element.
childrenReactNodeCustom menu items rendered alongside Buy Credits and Sign Out.

<QuotaConnectButton />

Use when Quota sits alongside your existing auth (the wallet-linking pattern) — the user is already signed into your app and is now linking their Quota wallet. Same underlying OAuth flow as <QuotaSignInButton />, different copy. Scopes are configured on the server via createQuotaRouteHandlers({ scopes }).

<QuotaConnectButton
  variant="primary"
  showWhenConnected={false}
  onSuccess={() => toast.success("Quota connected")}
/>

Testing & demos

<MockQuotaProvider /> is a drop-in replacement for <QuotaProvider /> that injects a fixed auth state instead of fetching from your API routes. Use it in Storybook, playgrounds, and snapshot tests. login(), logout(), and refetch() are no-ops.

Default mock user
Out of the box the mock surfaces { id: "usr_mock_001", email: "dev@example.com", balance: 5_000_000 } ($5.00). Pass user to override or user={null} for the signed-out branch. DEFAULT_MOCK_PACKAGES is also exported.
import type { Preview } from "@storybook/react";
import { MockQuotaProvider } from "@usequota/nextjs";

const preview: Preview = {
  decorators: [
    (Story, context) => (
      <MockQuotaProvider {...(context.parameters.quota || {})}>
        <Story />
      </MockQuotaProvider>
    ),
  ],
};

export default preview;
userQuotaUser | nullOverride the authenticated user. null renders the signed-out branch.
claimsIdTokenClaims | nullVerified OIDC id_token claims surfaced through useQuota().claims.
idTokenstring | nullRaw id_token JWT surfaced through useQuota().idToken.
isLoadingbooleanForce the loading branch.
errorError | nullForce the error branch.
baseUrlstringBase URL the mock pretends to be. Defaults to https://api.usequota.ai.
packagesCreditPackage[] | Promise<CreditPackage[]>When set, intercepts /v1/packages fetches and serves this list. Pass a rejected promise to exercise the error branch.

04 Hooks

useQuota()

The full provider context: user, claims, idToken, isLoading, error, login, logout, refetch, baseUrl.

"use client";
import { useQuota, formatCredits } from "@usequota/nextjs";

export function AccountWidget() {
  const { user, isLoading, logout } = useQuota();

  if (isLoading) return null;
  if (!user) return <p>Sign in to see your balance.</p>;

  return (
    <div>
      <p>Hi, {user.email}</p>
      {user.balance === null ? (
        <p>No wallet linked — connect one to start spending.</p>
      ) : (
        <p>
          Balance: <strong>{formatCredits(user.balance)}</strong>
        </p>
      )}
      <button onClick={logout}>sign out</button>
    </div>
  );
}
user.balance may be null
A signed-in user can exist without a linked Quota wallet (e.g. signed in via <QuotaSignInButton /> but no wallet connected). Show a “Link wallet” affordance — don't display $0.00, which is a different state.

useQuotaUser() / useQuotaAuth()

useQuotaUser() returns the current QuotaUser or null. useQuotaAuth() returns { isAuthenticated, isLoading, login, logout }. Convenience wrappers around useQuota().

useQuotaBalance()

Returns the user's current balance in credits, or null when signed out or when no wallet is linked. Divide by 1_000_000 for dollars — or use formatCredits(), which handles null and sub-cent precision.

"use client";
import { useQuotaBalance, formatCredits } from "@usequota/nextjs";

export function CreditDisplay() {
  const { balance, isLoading, error } = useQuotaBalance();

  if (isLoading) return <div>Loading…</div>;
  if (error) return <div>Couldn&apos;t load balance</div>;

  return <div>Balance: {formatCredits(balance)}</div>;
}

useQuotaPackages()

Fetches available credit packages via the local /api/quota/packages route handler. Auto-fetches on mount. Pass packagesPath if you mounted the route at a non-default path. Returns { packages, isLoading, error, refetch }; each package carries id, credits, price_cents, and a preformatted price_display string.

useVoiceConversion()

React hook for the V2V WebSocket relay. Handles the connect / auth-frame handshake, captures mic audio via getUserMedia piped through Quota's PCM AudioWorklet encoder, and streams the converted audio back through a MediaSource so playback starts on the first chunk. Use it for voice-clone UIs, real-time dubbing demos, and similar bidirectional audio flows. See the underlying wire protocol if you want to know exactly what's on the wire.

"use client";
import { useVoiceConversion } from "@usequota/nextjs";

export function VoiceCloneButton() {
  const { start, stop, state, error, secondsUsed } = useVoiceConversion({
    model: "elevenlabs/voice-conversion-v1",
    voice: "rachel",
    format: "pcm_16le_16k_mono",
    maxDurationSeconds: 600,
    onClose: (code) => {
      if (code === "BALANCE_EXHAUSTED") {
        // Send the user to the top-up flow.
      }
    },
  });

  if (error) return <p className="error">{error.message}</p>;

  return (
    <div>
      <button onClick={state === "open" ? stop : start}>
        {state === "open" ? "Stop" : "Speak"}
      </button>
      {state === "open" && <span>{secondsUsed.toFixed(1)}s</span>}
    </div>
  );
}
modelstringrequiredMust be elevenlabs/voice-conversion-v1 today.
voicestringrequiredVoice name or ElevenLabs voice ID — same set as TTS.
format"pcm_16le_16k_mono" | "pcm_16le_24k_mono" | "pcm_16le_16k_stereo"requiredPCM input shape. The hook's AudioWorklet downsamples the mic stream to match — pick the lowest rate that gives acceptable quality for your application (16 kHz mono is the default for speech).
maxDurationSecondsnumberOptional cap (default 1800 = 30 min hard ceiling). Sized proportionally to the up-front credit reservation — smaller values lower the hold but also lower the budget before BALANCE_EXHAUSTED fires.
onClose(code: V2VCloseCode, reason: string) => voidCalled once on session end. Typed close codes match the wire protocol. Use this to route the user into top-up, support, or reconnect flows.

Returns { start, stop, state, error, secondsUsed, sessionId }. The state machine is idle → connecting → open → closed; error is a typed QuotaError (the same class chat/balance return). Token resolution mirrors useQuota() — if the user is signed in via the provider, the hook attaches their OAuth token automatically; otherwise it falls back to your developer API key on the server.

05 Server utilities

Use these in Server Components, Route Handlers, and Server Actions. Import from @usequota/nextjs/server.

getQuotaUser(config) / requireQuotaAuth(config)

getQuotaUser returns the authenticated QuotaUser or null. requireQuotaAuth is the same but redirects to / when unauthenticated — meant for top-of-page guard clauses.

import { getQuotaUser } from "@usequota/nextjs/server";

export default async function Page() {
  const user = await getQuotaUser({
    clientId: process.env.QUOTA_CLIENT_ID!,
    clientSecret: process.env.QUOTA_CLIENT_SECRET!,
  });

  if (!user) return <p>Sign in to continue.</p>;
  return <p>Hi, {user.email}</p>;
}

getQuotaPackages(config?)

Returns the available packages list. Network and non-2xx responses are swallowed — the helper logs and returns [] rather than throwing, so don't rely on try/catch alone.

createQuotaCheckout(config)

import { createQuotaCheckout } from "@usequota/nextjs/server";

const { url, sessionId } = await createQuotaCheckout({
  clientId: process.env.QUOTA_CLIENT_ID!,
  clientSecret: process.env.QUOTA_CLIENT_SECRET!,
  packageId: "starter",
  successUrl: "https://example.com/billing/done",
  cancelUrl: "https://example.com/billing",
});

clearQuotaAuth()

Deletes the access token, refresh token, and external user ID cookies. Use from a Server Action or logout route.

06 Route handler factory reference

createQuotaRouteHandlers(config) returns one async function per route. Re-export each from app/api/quota/*/route.ts as the corresponding HTTP method.

Returned handlers

authorizeGETInitiates the OAuth flow. Redirects to /oauth/authorize on the Quota API with PKCE state.
callbackGETOAuth callback — exchanges code for tokens, sets cookies, redirects.
meGETUsed by QuotaProvider to fetch the signed-in user. Returns QuotaUser or 401.
logoutPOSTUsed by QuotaProvider.logout(). Clears auth cookies.
statusGETReturns connection status and balance for the current user.
packagesGETReturns purchasable packages — proxied to /v1/packages.
checkoutPOSTCreates a Stripe checkout session and returns the URL.
disconnectPOSTCalls deleteUserLink and clears local cookies.

Config reference

clientIdstringrequiredOAuth client ID.
clientSecretstringOAuth client secret. Optional for public clients (PKCE-only).
baseUrlstringDefaults to https://api.usequota.ai.
cookiePrefixstringDefaults to "quota".
cookieMaxAgenumberDefaults to 604800 (7 days).
callbackPathstringDefaults to /api/quota/callback.
successRedirectstringWhere to send the user after a successful OAuth callback. Defaults to /.
errorRedirectstringDefaults to /.
tokenStorageQuotaTokenStoragePluggable storage adapter. Defaults to cookie storage.
callbacks.onConnect(ctx) => Promise<void>Fires after a successful OAuth callback, before the redirect.
callbacks.onDisconnect(ctx) => Promise<void>Fires when the user disconnects their wallet.

07 withQuotaAuth

Wraps an API route handler with automatic Quota auth. Reads the access token, fetches the user, refreshes expired tokens, and converts any thrown QuotaError into a structured HTTP response.

import { withQuotaAuth } from "@usequota/nextjs/server";

export const POST = withQuotaAuth(
  {
    clientId: process.env.QUOTA_CLIENT_ID!,
    clientSecret: process.env.QUOTA_CLIENT_SECRET!,
  },
  async (request, { user, accessToken }) => {
    const body = await request.json();
    const response = await fetch(
      "https://api.usequota.ai/v1/chat/completions",
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${accessToken}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          model: "gpt-4o-mini",
          messages: body.messages,
        }),
      }
    );
    return Response.json(await response.json());
  }
);

08 Webhooks

The webhook helpers live in @usequota/core and are re-exported. Use createWebhookHandler for the common case; parseWebhook or verifyWebhookSignature for custom logic.

verifyWebhookSignature is async — await it
verifyWebhookSignature returns Promise<boolean>. Without await, if (isValid) branches on a Promise (always truthy) and silently bypasses signature verification.
import { createWebhookHandler } from "@usequota/nextjs";

export const POST = createWebhookHandler(
  process.env.QUOTA_WEBHOOK_SECRET!,
  {
    "balance.low": async (event) => {
      await sendLowBalanceEmail(event.data.user_id);
    },
    "user.connected": async (event) => {
      await db.quotaLinks.create({
        data: { quotaUserId: event.data.user_id },
      });
    },
    "usage.completed": async (event) => {
      await analytics.track("quota.usage", event.data);
    },
  }
);

09 Typed errors

All error classes from @usequota/core are re-exported. Match with instanceof for branchy recovery.

import {
  QuotaError,
  QuotaInsufficientCreditsError,
  QuotaNotConnectedError,
  QuotaRateLimitError,
  errorFromResponse,
} from "@usequota/nextjs/server";

try {
  const response = await fetch(/* ... */);
  if (!response.ok) {
    const error = await errorFromResponse(response);
    if (error) throw error;
  }
} catch (e) {
  if (e instanceof QuotaInsufficientCreditsError) {
    showBuyCreditsDialog({ short: e.required });
  } else if (e instanceof QuotaNotConnectedError) {
    redirectToLogin();
  } else if (e instanceof QuotaRateLimitError) {
    await new Promise((r) => setTimeout(r, e.retryAfter * 1000));
  } else if (e instanceof QuotaError) {
    console.error(`[${e.code}] ${e.message}`);
  }
}
402QuotaInsufficientCreditsErrorWallet can't cover the reservation. Carries balance and required (in credits).
401QuotaNotConnectedErrorThe user has not linked a Quota wallet to your app.
401QuotaTokenExpiredErrorOAuth access token expired and could not be refreshed.
429QuotaRateLimitErrorRate limit exceeded. Wait retryAfter seconds (default 60).
*QuotaErrorBase class. Carries code, statusCode, optional hint.

10 Provider reference

clientIdstringrequiredOAuth client ID. Use the NEXT_PUBLIC_ env var so the browser bundle can see it.
baseUrlstringDefaults to https://api.usequota.ai.
apiPathstringThe local route returning the current user. Defaults to /api/quota/me.
logoutPathstringDefaults to /api/quota/logout.
fetchStrategy"eager" | "lazy""eager" (default) fetches the user on mount. "lazy" defers until refetch() is called manually.