Server API
Nitro helpers from @lupinum/better-convex-nuxt/server and Convex helpers from @lupinum/better-convex-nuxt/better-auth/server.
Better Convex has two server entries:
@lupinum/better-convex-nuxt/serverruns in Nitro, the Nuxt server. Use it inserver/routes.@lupinum/better-convex-nuxt/better-auth/serverruns in Convex. Use it inconvex/files.
Nitro helpers
The module auto-imports serverConvex, getConvexUser, requireConvexUser, and toConvexH3Error in Nitro code. To import them explicitly, use the #convex/server alias:
import { getConvexUser, requireConvexUser, serverConvex, toConvexH3Error } from '#convex/server'Outside Nuxt aliases, import from @lupinum/better-convex-nuxt/server. That entry also exports the ServerConvexValidationError class and the ServerConvexCaller, ServerConvexOptions, and ConvexCredential types.
serverConvex
serverConvex(event: H3Event, options?: ServerConvexOptions): ServerConvexCallerCreates a caller for one request. Create a new caller in each request. Do not store it in a module variable.
options has one of three forms:
type ServerConvexOptions =
| { auth?: 'required' | 'optional' | 'none' }
| { authToken: string }
| { credential: ConvexCredential }
type ConvexCredential = { type: 'cookie'; value: string }| Form | Identity used |
|---|---|
auth | The Better Auth session cookie of the request. The default is 'optional'. convex.auth.defaultQueryAuth does not apply. |
authToken | A Convex session token that you already have. Authentication is required. |
credential | A Better Auth Cookie header string. Authentication is required. |
The three forms cannot be combined. Do not add auth to the authToken or credential form. A raw Better Auth session token is not accepted as authToken.
The caller has these methods:
getToken(): Promise<string | null>
query(query, ...args): Promise<Result>
mutation(mutation, ...args): Promise<Result>
action(action, ...args): Promise<Result>getToken() returns the Convex session token for this request, or null for an anonymous request. A function without arguments needs no {} argument. A function with declared arguments needs its argument object.
Calls reject with a ConvexCallError that has functionName. A missing required user and a failed token exchange use kind authentication and code UNAUTHENTICATED. Invalid options throw ServerConvexValidationError before any network request.
A query stops after convex.server.queryTimeoutMs (default 8 seconds), a mutation after 15 seconds, and an action after 60 seconds. A response larger than convex.server.maxResponseBytes (default 1 MiB) fails. See module configuration.
The cookie form always needs the request. The package has no helper that turns a cookie into a token outside a request, because rate limiting needs the client IP of the request.
getConvexUser
getConvexUser(event: H3Event): Promise<ConvexUser | null>Returns the signed-in user of the request, or null for an anonymous request. A build without auth always returns null.
The user is read from the Convex session token. It always has id. Profile fields such as name, email, and image are present only when defineSessionClaims adds them; see Session claims. The same holds for requireConvexUser.
The module exchanges the session cookie for a Convex session token at most once per request. Server rendering, repeated getConvexUser and requireConvexUser calls, and serverConvex(event) calls that use the request cookie share that result. So the user that a route shows is the user that Convex checks.
It rejects with an H3 error when it cannot find out who the user is: 502 when the auth backend fails, and 500 when auth is on but no Convex site URL is set. The error body contains no credentials.
Use the user for routing and display. Check access to data inside Convex functions, for example through serverConvex(event, { auth: 'required' }).
requireConvexUser
requireConvexUser(event: H3Event): Promise<ConvexUser>Returns the signed-in user, or throws an H3 401 error. The response data is a serialized ConvexCallError with kind authentication and code UNAUTHENTICATED. Missing, invalid, and revoked sessions all give 401. A build without auth always throws it. Other failures reject as for getConvexUser.
export default defineEventHandler(async (event) => {
setHeader(event, 'cache-control', 'private, no-store')
const user = await requireConvexUser(event)
return { id: user.id }
})toConvexH3Error
toConvexH3Error(error: unknown): H3Error<SerializedConvexCallError>Converts any thrown value with normalizeConvexError and returns an H3 error for a Nitro response. The HTTP status depends on the error kind:
| Kind | HTTP status |
|---|---|
authentication | 401, or 403 when the error has status 403 |
transport | 502 |
server | The application's 4xx status, otherwise 400 |
unknown | 500 |
The H3 error data is error.toJSON() without functionName. It keeps the data of your ConvexError. It never contains a stack, a credential, or the Convex function path. Log functionName from the original error on the server. Map the error yourself when a route must not show the application data. In the browser, normalizeConvexError turns the FetchError from $fetch or useFetch back into the same ConvexCallError.
import { api } from '#convex/api'
import { z } from 'zod'
const bodySchema = z.object({ title: z.string().min(1) })
export default defineEventHandler(async (event) => {
const { title } = await readValidatedBody(event, bodySchema.parse)
try {
return await serverConvex(event, { auth: 'required' }).mutation(api.notes.create, { title })
} catch (error) {
throw toConvexH3Error(error)
}
})The Convex argument validator and the Convex function still check the input.
Convex auth helpers
Import these helpers in convex/ files:
import { createBetterConvexAuth } from '@lupinum/better-convex-nuxt/better-auth/server'createBetterConvexAuth
createBetterConvexAuth<DataModel>(
component: typeof components.betterAuth,
options?: CreateBetterConvexAuthOptions<DataModel>,
): BetterConvexAuth<DataModel>Creates the Better Auth setup for one Convex deployment. It is the only way to configure Better Auth with Better Convex. Call it once in convex/auth.ts and export the result as auth.
| Option | Effect |
|---|---|
appName | Application name that Better Auth shows, for example in two-factor apps |
authFunctions | References to the trigger functions, usually internal.auth. Without it, triggers do not run. |
triggers | onCreate, onUpdate, and onDelete handlers for each auth model, such as user. They run in the same mutation as the change. |
beforeUserCreate | Decide whether a new user may sign up. Return { allowed: false } to refuse. |
email | One function that sends every auth email: verification, password reset, one-time codes, two-factor codes, and invitations |
emailAndPassword | Email and password sign-in options, or false to turn it off. passwordReset: true adds link-based password reset. |
emailVerification | Email verification options |
emailOTP | Email one-time-code options, or false |
twoFactor | Two-factor options, or false |
organization | Organization options, or false |
socialProviders | Better Auth social provider settings, or a function that returns them |
account | accountLinking.trustedProviders only. Linking accounts with different emails stays off. |
session | expiresIn (1 hour to 30 days, default 7 days), updateAge (default 1 day), and cookieCache, all in seconds |
defineSessionClaims | Add claims to the Convex session token. By default it carries only the library claims; profile fields such as name and email are opt-in. |
oauth | { mcp: BetterConvexMcpOptions }. Turns on OAuth for MCP hosts. See below. |
oauthProvider | A hand-written OAuth provider profile. Use oauth.mcp instead unless you need a different profile. |
oauth.mcp accepts scopes (required, scope name to consent text), resource (default /mcp), hosts ('chatgpt', 'claude'), renewal (default true), loginPage (default /login), and consentPage (default /oauth/consent).
The returned auth object has these members. The cost column lists the database work of one call.
| Member | Use | Cost |
|---|---|---|
createAuth(ctx) | Create the Better Auth instance for one Convex function. Export it from convex/auth.ts. | None until you call Better Auth |
registerRoutes(http) | Add the /api/auth/* routes to the Convex HTTP router | None |
getUser(ctx) | The signed-in user, or null. Revoked, expired, and replaced sessions give null. | One component query |
requireUser(ctx) | Like getUser, but throws ConvexError({ code: 'UNAUTHENTICATED' }) | One component query |
getAuth(ctx) | { auth, headers } for auth.api calls as the signed-in user, from a mutation or action | One component query |
sessionHttpAction(handler) | Wrap an HTTP action that must act as the caller's session. The handler gets { request, auth, headers, user, sessionId }. | One component query per request |
triggerFunctions() | onCreate, onUpdate, and onDelete internal mutations for triggers. Export them from convex/auth.ts. | Your trigger code |
jwksOperatorFunctions() | ensureSigningKey, rotateSigningKey, and pruneSigningKeys internal functions for signing keys | Runs only when you call them |
mcp | issuer(), resource(), scopes(), and scopesSupported() of the oauth.mcp settings | None |
createMcpAccessVerifier(ctx, options?) | The token verifier for handleMcpRequest | One component query per token |
requireMcpPrincipal(ctx, principal, { scope }) | Check an MCP principal and its scope again inside your function. Returns { user, principal }. | One component query |
oauthConnections.list(ctx, { userId }) | Up to 100 current grants of a user, newest first | One component query, plus one per grant to read the client name |
oauthConnections.revoke(ctx, { userId, clientId }) | Delete the consent and the refresh tokens of one user and client. Returns { revoked }. | Up to 9 component mutations |
oauthOperator | createHostClient, createPublicClient, setClientDisabled, and deleteClient for OAuth clients. Call them from internal functions that only operators run. | Several component calls |
createMcpAccessVerifier and requireMcpPrincipal are the only public OAuth access checks. Every access token is bound to the consent that issued it, so a revoked consent stops the token even after the person grants the same access again. A disabled OAuth resource rejects tokens that were already issued. requireMcpPrincipal throws a ConvexError with code MCP_ACCESS_DENIED or MCP_INSUFFICIENT_SCOPE. Pass oauthConnections the ID of the signed-in user, for example from requireUser. Never take userId from client input.
A call that reads the component inside a Convex query makes that query depend on the session and grant rows. When a session or grant is revoked, the query runs again and the check fails.
mcpPrincipalValidator
A Convex validator for BetterConvexMcpPrincipal. Use it for the principal argument of internal functions that an MCP tool calls:
export const list = internalQuery({
args: { principal: mcpPrincipalValidator },
handler: async (ctx, { principal }) => {
const { user } = await auth.requireMcpPrincipal(ctx, principal, {
scope: 'notes:read',
})
return await ctx.db
.query('notes')
.withIndex('by_owner', (q) => q.eq('ownerId', user.id))
.take(20)
},
})Never use it on a public query, mutation, or action. A public function would let any caller name another user.
createUserProjectionTriggers
createUserProjectionTriggers(options): { user: { onCreate, onUpdate, onDelete, rebuild } }Keeps a copy of Better Auth users in one of your tables, for example users. Pass the user handlers to the triggers option of createBetterConvexAuth. Better Auth stays the source of the user data.
| Option | Required | Effect |
|---|---|---|
table | Yes | Your table, for example 'users' |
index | Yes | The index on the Better Auth user ID, for example 'by_auth_id' |
authIdField | No | The field that stores the Better Auth user ID. Default: 'authId'. |
createDoc | Yes | Builds the new document from { ctx, user, now } |
patchDoc | No | Builds a patch when the user changes. Return null to skip. |
rebuildDoc | No | Builds a patch for rebuild |
Each handler reads one row through index and writes at most one row. rebuild(ctx, users) processes a list of users and returns { inserted, patched, skipped }. User synchronization guide.
findAccountKeyCollisions
findAccountKeyCollisions(ctx, component, { pageSize?, maxPages?, cursor? }): Promise<AccountKeyCollisionReport>Finds auth accounts that share one provider and provider account ID. Only an upgrade from a 1.0 beta can create them. Run it once after you deploy 1.0, from an internal action:
import { findAccountKeyCollisions } from '@lupinum/better-convex-nuxt/better-auth/server'
import { v } from 'convex/values'
import { components } from './_generated/api'
import { internalAction } from './_generated/server'
export const checkAccountKeys = internalAction({
args: { cursor: v.optional(v.union(v.string(), v.null())) },
handler: (ctx, { cursor }) => findAccountKeyCollisions(ctx, components.betterAuth, { cursor }),
})It returns { scannedAccounts, collisions, isDone, continueCursor }. Each collision has providerId, accountId, and accounts, a list of { id, userId }. One call reads at most maxPages queries (1 to 1000, default 100) of at most pageSize rows (1 to 100, default 100), so at most 10,000 rows by default. When isDone is false, call it again with cursor: continueCursor. Each collision is reported by exactly one call, also when its rows span two calls; together, the calls report every collision. It writes nothing. It throws instead of returning a partial report when the rows do not arrive in index order. Upgrade guide.
Configuration helpers
| Export | Use |
|---|---|
getConvexAuthProvider(env?) | Returns the auth provider entry for convex/auth.config.ts. It reads CONVEX_SITE_URL. |
requireAuthOrigin(name, env?) | Reads SITE_URL or CONVEX_SITE_URL from the environment and checks that it is a bare origin. Throws otherwise. |
defineAuthAdapterFunctions({ schema, metadata }) | Creates the adapter functions for a local auth component. You need it only with Better Auth plugins that change the schema. See Better Auth plugins. |
import { getConvexAuthProvider } from '@lupinum/better-convex-nuxt/better-auth/server'
import type { AuthConfig } from 'convex/server'
export default {
providers: [getConvexAuthProvider()],
} satisfies AuthConfig