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/nextjsSet 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:300002 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). |
| hideLogoboolean | Hide the leading Q / provider logo. Defaults to false. |
| childrenReactNode | Override 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". |
| showIconboolean | Show the leading icon. Defaults to true. |
| showRefreshboolean | Render a refresh button next to the balance. Defaults to false. |
| onClick() => void | When set, renders the pill as a button. Useful for linking to a top-up page. |
| ariaLabelstring | Accessible 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>
);
}| packageIdstring | Specific package id — one of starter, basic, plus, pro (or whatever /v1/packages returns). Skips the picker. |
| amountnumber | Desired credit amount. The SDK picks the smallest package whose credits >= amount. Ignored when packageId is provided. |
| checkoutPathstring | Path to your checkout API route. Defaults to /api/quota/checkout. |
| packagesPathstring | Path to your packages API route. Defaults to /api/quota/packages. |
| classNamestring | Class names for the wrapper div (root element). Use this for layout. |
| buttonClassNamestring | Class names for the inner button. |
| variant"primary" | "secondary" | "ghost" | Defaults to "primary". |
| onSuccess() => void | Fires after a verified balance increase post-checkout. |
| onCheckoutClosed() => void | Fires whenever the checkout window closes, regardless of outcome. |
| onCancel() => void | Fires when the window closed but balance did not change. |
| onError(error: Error) => void | Fires 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)}
/>| showBuyCreditsboolean | Whether to show the 'Buy Credits' item. Defaults to true. |
| onBuyCredits() => void | Fires when the user clicks 'Buy Credits'. Wire to navigation or a modal. |
| onSuccess() => void | Fires after logout() resolves successfully. |
| onError(err: Error) => void | Fires when logout() rejects. Without it the error is swallowed silently. |
| endQuotaSessionboolean | When true, also ends the Quota IdP session via OIDC RP-initiated logout. Defaults to false (local-only logout). |
| classNamestring | Class names appended to the root element. |
| childrenReactNode | Custom 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.
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 | null | Override the authenticated user. null renders the signed-out branch. |
| claimsIdTokenClaims | null | Verified OIDC id_token claims surfaced through useQuota().claims. |
| idTokenstring | null | Raw id_token JWT surfaced through useQuota().idToken. |
| isLoadingboolean | Force the loading branch. |
| errorError | null | Force the error branch. |
| baseUrlstring | Base 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>
);
}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'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>
);
}| modelstringrequired | Must be elevenlabs/voice-conversion-v1 today. |
| voicestringrequired | Voice name or ElevenLabs voice ID — same set as TTS. |
| format"pcm_16le_16k_mono" | "pcm_16le_24k_mono" | "pcm_16le_16k_stereo"required | PCM 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). |
| maxDurationSecondsnumber | Optional 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) => void | Called 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
| authorizeGET | Initiates the OAuth flow. Redirects to /oauth/authorize on the Quota API with PKCE state. |
| callbackGET | OAuth callback — exchanges code for tokens, sets cookies, redirects. |
| meGET | Used by QuotaProvider to fetch the signed-in user. Returns QuotaUser or 401. |
| logoutPOST | Used by QuotaProvider.logout(). Clears auth cookies. |
| statusGET | Returns connection status and balance for the current user. |
| packagesGET | Returns purchasable packages — proxied to /v1/packages. |
| checkoutPOST | Creates a Stripe checkout session and returns the URL. |
| disconnectPOST | Calls deleteUserLink and clears local cookies. |
Config reference
| clientIdstringrequired | OAuth client ID. |
| clientSecretstring | OAuth client secret. Optional for public clients (PKCE-only). |
| baseUrlstring | Defaults to https://api.usequota.ai. |
| cookiePrefixstring | Defaults to "quota". |
| cookieMaxAgenumber | Defaults to 604800 (7 days). |
| callbackPathstring | Defaults to /api/quota/callback. |
| successRedirectstring | Where to send the user after a successful OAuth callback. Defaults to /. |
| errorRedirectstring | Defaults to /. |
| tokenStorageQuotaTokenStorage | Pluggable 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.
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}`);
}
}balance and required (in credits).retryAfter seconds (default 60).code, statusCode, optional hint.10 Provider reference
| clientIdstringrequired | OAuth client ID. Use the NEXT_PUBLIC_ env var so the browser bundle can see it. |
| baseUrlstring | Defaults to https://api.usequota.ai. |
| apiPathstring | The local route returning the current user. Defaults to /api/quota/me. |
| logoutPathstring | Defaults to /api/quota/logout. |
| fetchStrategy"eager" | "lazy" | "eager" (default) fetches the user on mount. "lazy" defers until refetch() is called manually. |