Skip to main content

Plain Vue and Vite

Use the Better Convex composables in a Vue app without Nuxt.

@lupinum/better-convex-vue gives a plain Vue app the same browser composables that the Nuxt module uses: queries, pagination, mutations, actions, forms, file uploads, and connection state. It clears user data when the user changes.

It does not depend on Nuxt or Better Auth. Use the Nuxt module when you need server rendering, Nuxt server routes, or the built-in Better Auth setup.

Install

bash
pnpm add @lupinum/better-convex-vue@next convex@^1.42.2 vue@^3.5.0

The package needs vue >=3.5.0 <4 and convex >=1.42.2 <2. The next dist-tag is the 1.0 release candidate.

Anonymous application

Install one plugin at the Vue application root:

src/main.ts
import { createApp } from 'vue'
import { createBetterConvex } from '@lupinum/better-convex-vue'

import App from './App.vue'

const app = createApp(App)

app.use(
  createBetterConvex({
    convexUrl: import.meta.env.VITE_CONVEX_URL,
  }),
)

app.mount('#app')

Set VITE_CONVEX_URL in .env.local to your deployment URL. It ends in .convex.cloud. The Convex dashboard shows it in the deployment settings.

Then use the composables in components. They return refs right away. You do not await them:

src/components/Notes.vue
<script setup lang="ts">
import { api } from '../../convex/_generated/api'
import { useConvexMutation, useConvexQuery } from '@lupinum/better-convex-vue'

const { data: notes, status, error } = useConvexQuery(api.notes.list, {})
const { mutate: renameNote, pending: renaming } = useConvexMutation(api.notes.rename)
</script>

<template>
  <p v-if="status === 'pending'">Loading…</p>
  <p v-else-if="error">Could not load notes.</p>
  <ul v-else>
    <li v-for="note in notes" :key="note._id">
      <button :disabled="renaming" @click="renameNote({ id: note._id, title: `${note.title}!` })">
        {{ note.title }}
      </button>
    </li>
  </ul>
</template>

A query with args: {} may omit the arguments. Every other query needs an argument object or 'skip'. Pass a computed value that returns 'skip' to pause a query. A skipped query stops its subscription and clears its data.

Query data is undefined until a value exists. A backend null result is successful data, not a loading or skip sentinel.

Client options

Pass supported ConvexClient options with clientOptions:

ts
app.use(
  createBetterConvex({
    convexUrl: import.meta.env.VITE_CONVEX_URL,
    clientOptions: { verbose: import.meta.env.DEV },
  }),
)

clientOptions accepts verbose, webSocketConstructor, skipConvexDeploymentUrlCheck, and unsavedChangesWarning. Any other key throws a TypeError, because the package manages auth and client replacement. unsavedChangesWarning defaults to false. The type is BetterConvexClientOptions. An embedded app that uses attachment cannot pass clientOptions.

Authentication with any provider

The Vue package works with any identity provider. You write an auth adapter. The adapter reports the provider's session state and returns a short-lived Convex token:

src/convex-auth.ts
import type { BetterConvexAuthAdapter } from '@lupinum/better-convex-vue'

// Replace this with your identity provider's browser SDK.
interface ProviderSession {
  status: 'loading' | 'authenticated' | 'anonymous' | 'error'
  userId: string | null
  generation: number
  error: Error | null
}
declare const provider: {
  session(): ProviderSession
  onChange(listener: () => void): () => void
  refresh(): Promise<void>
}

export const auth: BetterConvexAuthAdapter = {
  snapshot: () => {
    const session = provider.session()
    return {
      status: session.status,
      identityKey: session.userId,
      sessionGeneration: session.generation,
      error: session.error,
    }
  },
  subscribe: (listener) => provider.onChange(listener),
  fetchToken: async () => {
    // Your backend returns a Convex token for the current session.
    const response = await fetch('/api/convex-token', { credentials: 'include' })
    return response.ok ? ((await response.json()) as { token: string }).token : null
  },
  refreshSession: () => provider.refresh(),
}
  • identityKey is a stable user ID that is not secret. The package uses it only to keep one user's data apart from another's.
  • sessionGeneration must increase whenever the session changes, including a new session for the same user.
  • Neither value is a role or a permission.

Install the adapter with the plugin:

src/main.ts
app.use(
  createBetterConvex({
    convexUrl: import.meta.env.VITE_CONVEX_URL,
    auth,
  }),
)

Your backend issues the Convex token. Never put provider secrets, cookies, refresh tokens, deployment keys, or signing keys in the Vite bundle. Check permissions in every protected Convex function.

Embedded Vue applications

A Nuxt app can share its Convex connection with a separately built Vue app that runs on the same page. Install @lupinum/better-convex-vue@next in the Nuxt app too. Then read the attachment in a Nuxt component and pass it to the embedded app:

app/components/EmbeddedHost.vue
<script setup lang="ts">
import { createApp, type App } from 'vue'
import { createBetterConvex } from '@lupinum/better-convex-vue'

import EmbeddedApp from './EmbeddedApp.vue'

let embeddedApp: App | undefined

if (import.meta.client) {
  // useConvexAttachment() throws on the server.
  const attachment = useConvexAttachment()
  onMounted(() => {
    embeddedApp = createApp(EmbeddedApp)
    embeddedApp.use(createBetterConvex({ attachment }))
    embeddedApp.mount('#embedded')
  })
  onBeforeUnmount(() => embeddedApp?.unmount())
}
</script>

<template>
  <div id="embedded" />
</template>

The attachment lets the embedded app run queries and mutations as the current user. It contains no token, cookie, or session. Do not create a second Convex client in the embedded app, and do not pass credentials to it.

What plain Vue does not include

  • Server rendering and hydration
  • Nuxt server routes and serverConvex()
  • Better Auth setup and the auth proxy
  • Route middleware
  • An MCP server
  • Roles and permissions

Use @lupinum/better-convex-nuxt for the Nuxt features. Use @lupinum/better-convex-mcp for an MCP server.