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
pnpm add @lupinum/better-convex-vue@next convex@^1.42.2 vue@^3.5.0The 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:
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:
<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:
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:
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(),
}identityKeyis a stable user ID that is not secret. The package uses it only to keep one user's data apart from another's.sessionGenerationmust 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:
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:
<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.