---
title: "Better Auth"
description: "The Vue/Nuxt port of @convex-dev/better-auth — useBetterAuth, the same-origin proxy, SSR prefetch, the auth middleware, and AuthBoundary."
canonical_url: "https://nuxt-convex-module.dev/components/better-auth"
---
# Better Auth

> The Vue/Nuxt port of @convex-dev/better-auth — useBetterAuth, the same-origin proxy, SSR prefetch, the auth middleware, and AuthBoundary.

A Vue/Nuxt port of `@convex-dev/better-auth`'s `react` + `nextjs` integration. [Better Auth](https://www.better-auth.com) owns the sessions and the sign-in flows; this integration connects them to Convex and to Nuxt's server layer.

It is **not** a default — exactly as in Convex's upstream integration, where it is a separate package. Install it only if you need it.

<upstream-baseline source="better-auth">

Matches upstream @convex-dev/better-auth@0.12.5 (react + nextjs + client plugins).

</upstream-baseline>

## Install

<pm-install packages="@convex-dev/better-auth@~0.12.0 better-auth@~1.6.11">

```bash [pnpm]
pnpm add @convex-dev/better-auth@~0.12.0 better-auth@~1.6.11
```

```bash [npm]
npm i @convex-dev/better-auth@~0.12.0 better-auth@~1.6.11
```

```bash [yarn]
yarn add @convex-dev/better-auth@~0.12.0 better-auth@~1.6.11
```

```bash [bun]
bun add @convex-dev/better-auth@~0.12.0 better-auth@~1.6.11
```

</pm-install>

That is the whole setup. The integration auto-enables on the next dev run; there is no `modules` entry to add.

## Configure

Everything below is optional — set it under the `convex` key in `nuxt.config.ts`:

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Purpose
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        betterAuth
      </code>
    </td>
    
    <td>
      <code>
        boolean | BetterAuthModuleOptions
      </code>
    </td>
    
    <td>
      auto-detect
    </td>
    
    <td>
      Force the integration on or off. Auto-detection turns it on when <code>
        @convex-dev/better-auth
      </code>
      
       is in your <code>
        package.json
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        betterAuth.authClient
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      bundled client
    </td>
    
    <td>
      Path to your own auth-client module — see <a href="#bring-your-own-auth-client">
        bring your own auth client
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        betterAuth.crossDomainCallbackRoute
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        ''
      </code>
      
       (unrestricted)
    </td>
    
    <td>
      Restrict one-time-token consumption to a single route — see <a href="#cross-domain-auth">
        cross-domain auth
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        betterAuth.loginPath
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        /login
      </code>
    </td>
    
    <td>
      Where the <code>
        convex-auth
      </code>
      
       middleware sends unauthenticated visitors. The one route it never redirects away from.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        authRoute
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        /api/auth
      </code>
    </td>
    
    <td>
      Where the same-origin Better Auth proxy mounts. Changing it needs your own <code>
        authClient
      </code>
      
       created with the same <code>
        basePath
      </code>
      
       — the bundled client calls <code>
        /api/auth
      </code>
      
      , and the module errors at startup otherwise.
    </td>
  </tr>
</tbody>
</table>

The Better Auth endpoints live on your Convex deployment's `.site` origin, so `NUXT_PUBLIC_CONVEX_SITE_URL` must be set — see [configuration](https://nuxt-convex-module.dev/getting-started/configuration#runtime-config).

Convex-side setup (`convex/auth.ts` with `authComponent`, `convex/http.ts` registering the routes, and `convex/auth.config.ts`) is unchanged from [the React guide](https://convex-better-auth.netlify.app/framework-guides/react) of the Convex Better Auth component — the Convex half is framework-independent.

## Use

Installing the package gives you (auto-imported with the default [`convex.autoImports`](https://nuxt-convex-module.dev/getting-started/configuration#module-options-convex); under `'prefixed'` or `false`, import them from `nuxt-convex-module/better-auth/client` and `convexAuth` from `nuxt-convex-module/better-auth/server`):

- **useBetterAuth()** (auto-imported) — the Better Auth session, the Convex token fetcher, and the full Better Auth `client` for sign-in/out and any plugin flows. Its return shape is below.
- **/api/auth/**** — a registered same-origin proxy route to your Convex deployment's Better Auth endpoints (route configurable via `convex.authRoute`). The proxy answers `GET`, `HEAD`, `POST` and `OPTIONS`; anything else gets a `405` without reaching the deployment.
- **convex-auth route middleware** — opt a page in with `definePageMeta({ middleware: 'convex-auth' })`. Unauthenticated visitors are redirected on both server and client to `convex.betterAuth.loginPath` (default `/login`), with the original destination attached as `?redirect=`. It never redirects away from `loginPath` itself, so there is no loop; on the client it waits for the session to settle first, so a hard refresh of a protected page does not bounce a signed-in user. See [the login page](#the-login-page) for how to consume the query safely.
- **resolveAuthRedirect()** (auto-imported) — validates a `?redirect=` destination before you navigate to it.
- **usePreloadedAuthQuery()** (auto-imported) — hydrate an SSR-preloaded authenticated query. It shows the preloaded value until auth settles or a live result arrives, holds the last value while auth reloads, and yields `undefined` for a signed-out user.
- **convexAuth(event)** (auto-imported in Nitro server routes; also under the upstream-style alias `convexBetterAuthNuxt`) — a request-scoped authenticated client for server routes. See [authenticated server calls](https://nuxt-convex-module.dev/guide/server-and-ssr#authenticated-server-calls).

### `useBetterAuth()`

```vue [components/UserMenu.vue]
<script setup lang="ts">
const { user, client } = useBetterAuth()

// Auth flows live on the client, typed by the plugins *your* auth client
// installs — e.g. with `emailOTPClient()`:
// await client.signIn.emailOtp({ email, otp })
// await client.signOut()
</script>
```

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      What it is
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        isLoading
      </code>
    </td>
    
    <td>
      <code>
        ComputedRef<boolean>
      </code>
    </td>
    
    <td>
      The session is still resolving and no token is cached yet.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        isAuthenticated
      </code>
    </td>
    
    <td>
      <code>
        ComputedRef<boolean>
      </code>
    </td>
    
    <td>
      A session exists — or one is resolving and a token is already cached.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        user
      </code>
    </td>
    
    <td>
      <code>
        ComputedRef<BetterAuthUser | null>
      </code>
    </td>
    
    <td>
      <code>
        session.data.user
      </code>
      
      , or <code>
        null
      </code>
      
       while loading or signed out.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        session
      </code>
    </td>
    
    <td>
      <code>
        BetterAuthSession
      </code>
    </td>
    
    <td>
      Better Auth's own <code>
        useSession()
      </code>
      
       ref: <code>
        { data, isPending, error }
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        client
      </code>
    </td>
    
    <td>
      <code>
        AuthClient
      </code>
    </td>
    
    <td>
      The full Better Auth client — every sign-in, sign-out and plugin flow lives here. There is no <code>
        signIn
      </code>
      
       on the service itself.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        fetchAccessToken
      </code>
    </td>
    
    <td>
      <code>
        AuthTokenFetcher
      </code>
    </td>
    
    <td>
      The Convex token fetcher the client plugin wires into <code>
        setAuth
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        authVersion
      </code>
    </td>
    
    <td>
      <code>
        ComputedRef<string | null>
      </code>
    </td>
    
    <td>
      The session id (or user id) — changes when the session does, which re-registers auth on the Convex client.
    </td>
  </tr>
</tbody>
</table>

`useBetterAuth(initialToken?)` takes one optional argument, the token the server plugin prefetched. The client plugin passes it; you never need to.

Auth *state* — `isLoading` / `isAuthenticated`, and the `<Authenticated>` / `<Unauthenticated>` components — comes from the provider-agnostic core and is documented under [Auth State](https://nuxt-convex-module.dev/guide/auth-state).

### The login page

The `convex-auth` middleware sends unauthenticated visitors to `loginPath` with the page they wanted in `?redirect=`. Send them on with `resolveAuthRedirect()` rather than the raw query — it returns the destination only when it is a same-origin path, so a crafted `/login?redirect=https://evil.example` can't turn your login page into an [open redirect](https://nuxt-convex-module.dev/getting-started/security#post-sign-in-redirects):

```vue [pages/login.vue]
<script setup lang="ts">
const route = useRoute()
const { client } = useBetterAuth()
const email = ref('')
const password = ref('')

async function onSubmit() {
  await client.signIn.email({ email: email.value, password: password.value })
  await navigateTo(resolveAuthRedirect(route.query.redirect))
}
</script>
```

Pass your own landing page as the second argument when `/` isn't right: `resolveAuthRedirect(route.query.redirect, '/dashboard')`.

### Server rendering

During SSR the auth state the module provides is a stub: `isLoading` is `true`, `isAuthenticated` is `false`. So server HTML contains what `<AuthLoading>` renders and nothing from `<Authenticated>` or `<Unauthenticated>`; `useConvexAuth()` reads as loading for the whole server render, and the real state arrives with hydration. Expect a short loading state before signed-in content appears, and don't rely on `<Authenticated>` for content that must be in the initial HTML.

Signed-in data *can* be server-rendered — through the request, not the session: `convexAuth(event)` on the server, then `usePreloadedAuthQuery()` or `useAsyncQuery()` in the component. The server plugin prefetches the Convex token for the request so those calls run authenticated. See [authenticated server calls](https://nuxt-convex-module.dev/guide/server-and-ssr#authenticated-server-calls).

### Bring your own auth client

By default the integration ships a minimal Better Auth client carrying only `convexClient()` — the one plugin the Convex integration itself requires. This mirrors `@convex-dev/better-auth`'s React integration, where you author the `authClient` (with whatever plugins your app uses — OTP, passkeys, …) and pass it to `ConvexBetterAuthProvider`; here you point the module at your own module instead.

```ts [app/convex-auth-client.ts]
import { createAuthClient } from 'better-auth/vue'
import { convexClient, crossDomainClient } from 'nuxt-convex-module/better-auth/client'

export const authClient = createAuthClient({
  plugins: [convexClient(), crossDomainClient()], // add the plugins you need
})
export type AuthClient = typeof authClient
```

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  convex: { betterAuth: { authClient: './app/convex-auth-client' } },
})
```

The aliased client backs `useBetterAuth()`, the client plugin (session wiring and cross-domain token consumption), and the `AuthClient` type of `<AuthBoundary>`'s `authClient` prop — the boundary still takes the client instance as a required prop. The server SSR plugin doesn't touch it at all: it prefetches the token over HTTP via `convexAuth(event).getToken()`.

### Cross-domain auth

Adding `crossDomainClient()` enables cross-domain auth: on landing, `consumeCrossDomainOneTimeToken()` (called automatically by the client plugin) exchanges a `?ott=…` one-time token for a session. Leave it out for same-origin apps — the default client keeps cookies on the same origin.

<warning>

By default the one-time token is consumed from the URL on *any* page load and is not bound to the browser that started the sign-in flow — that is the cross-domain design, mirrored faithfully here. Tokens are single-use and expire in ~3 minutes, but while one is live it completes sign-in on whatever page receives it. Only enable `crossDomainClient()` when you actually serve auth across origins, and prefer the same-origin default (cookie sessions, no URL tokens) everywhere else.

When you do enable it, restrict token consumption to the one route your sign-in flows redirect to:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  convex: {
    betterAuth: {
      authClient: './app/convex-auth-client',
      crossDomainCallbackRoute: '/auth/callback', // ?ott= is ignored (and scrubbed) everywhere else
    },
  },
})
```

Point every `callbackURL` you pass to sign-in calls at that route. An `?ott=` link to any other page is then scrubbed without being exchanged, keeping sign-in completion to a single predictable URL.

</warning>

## `<AuthBoundary>`

<callout icon="i-lucide-flask-conical">

Experimental — upstream marks it so, and it may change in a minor release. See [experimental symbols](https://nuxt-convex-module.dev/getting-started/introduction#experimental).

</callout>

Redirects unauthenticated users reactively, and clears an invalid session before redirecting. It watches auth state on mount and on change, and doubles as an error boundary: an auth error thrown below it triggers the same redirect instead of propagating.

```vue [app.vue]
<script setup lang="ts">
import { authClient } from 'nuxt-convex-module/better-auth/client'
import { api } from '#convex/api'
import { isAuthError } from '~/utils/auth' // your own predicate
</script>

<template>
  <AuthBoundary
    :auth-client="authClient"
    :get-auth-user-fn="api.auth.getAuthUser"
    :is-auth-error="isAuthError"
    :on-unauth="() => navigateTo('/login')"
  >
    <slot />
  </AuthBoundary>
</template>
```

<table>
<thead>
  <tr>
    <th>
      Prop
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Purpose
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        on-unauth
      </code>
    </td>
    
    <td>
      <code>
        () => void | Promise<void>
      </code>
    </td>
    
    <td>
      required
    </td>
    
    <td>
      Called once the session is confirmed invalid — typically a redirect to your login page.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        auth-client
      </code>
    </td>
    
    <td>
      <code>
        AuthClient
      </code>
    </td>
    
    <td>
      required
    </td>
    
    <td>
      The Better Auth client instance. Called to clear cookies before <code>
        onUnauth
      </code>
      
       runs.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        get-auth-user-fn
      </code>
    </td>
    
    <td>
      <code>
        FunctionReference<'query'>
      </code>
    </td>
    
    <td>
      required
    </td>
    
    <td>
      A zero-argument Convex query returning the user, so the check stays reactive to provider-level auth state. Comes from <code>
        export const { getAuthUser } = authComponent.clientApi()
      </code>
      
       in your deployment.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        is-auth-error
      </code>
    </td>
    
    <td>
      <code>
        (error: unknown) => boolean
      </code>
    </td>
    
    <td>
      required
    </td>
    
    <td>
      Your predicate for "this error means unauthenticated". Auth errors are handled and stop propagating; everything else keeps propagating.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        render-fallback
      </code>
    </td>
    
    <td>
      <code>
        () => VNode | null
      </code>
    </td>
    
    <td>
      <code>
        () => null
      </code>
    </td>
    
    <td>
      Rendered while unauthenticated. Usually left alone, since the handling is a redirect.
    </td>
  </tr>
</tbody>
</table>

Only the `<AuthBoundary>` component itself is auto-registered — `authClient` must be imported (the export resolves through the `#convex/auth-client` alias to your configured client, so it's the same instance `useBetterAuth().client` returns), and `isAuthError` is your own error predicate.

## Reference

Full signatures: [`better-auth/client`](https://nuxt-convex-module.dev/api-reference/reference/better-auth/client) and [`better-auth/server`](https://nuxt-convex-module.dev/api-reference/reference/better-auth/server). Import types from `nuxt-convex-module/better-auth/client` (alias `/better-auth/vue`) and `nuxt-convex-module/better-auth/server`; both need Nuxt — they resolve the `#convex/auth-client` alias and runtime config. Upstream docs: [`@convex-dev/better-auth`](https://github.com/get-convex/better-auth) · [Better Auth](https://www.better-auth.com).

---

- [@convex-dev/better-auth](https://github.com/get-convex/better-auth)
- [Better Auth docs](https://www.better-auth.com/docs)


## Sitemap

See the full [sitemap](https://nuxt-convex-module.dev/sitemap.md) for all pages.
