Skip to Content

Vue.js Integration

⚡ 20 min read

Integrate Transcodes WebAuthn/Passkey authentication into your Vite Vue 3 application with TypeScript.


Prerequisites

  • Vite Vue 3 project with TypeScript
  • Transcodes project ID from Dashboard 
  • HTTPS environment (or localhost for development)

Installation

Add WebWorker Script

Add the Transcodes WebWorker script to your index.html:

index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- Transcodes WebWorker --> <script type="module" src="https://cdn.transcodes.link/%VITE_TRANSCODES_PROJECT_ID%/webworker.js" ></script> <title>My Vue App</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.ts"></script> </body> </html>

Vite automatically replaces %VITE_*% placeholders in index.html with environment variables.

Set Environment Variables

.env
VITE_TRANSCODES_PROJECT_ID=proj_abc123xyz

Add TypeScript Type Definitions

Download the complete type definitions from the API Reference and save as types/transcodes.d.ts.

Then update your tsconfig.json:

tsconfig.json
{ "compilerOptions": { "typeRoots": ["./node_modules/@types", "./types"] }, "include": ["src", "types"] }

Composable (useAuth)

Create a composable to manage authentication state with Vue 3 Composition API:

src/composables/useAuth.ts
import { ref, readonly, onMounted, onUnmounted } from 'vue'; import type { ApiResponse, AuthResult, AuthStateChangedPayload, User, } from '../../types/transcodes'; const projectId = import.meta.env.VITE_TRANSCODES_PROJECT_ID; // Shared state across all component instances const isAuthenticated = ref(false); const isLoading = ref(true); const userId = ref<string | null>(null); export function useAuth() { let unsubscribe: (() => void) | null = null; onMounted(async () => { // Check initial auth state const isAuth = await transcodes.token.isAuthenticated(); isAuthenticated.value = isAuth; isLoading.value = false; // Subscribe to AUTH_STATE_CHANGED event const handleAuthChange = (payload: AuthStateChangedPayload) => { isAuthenticated.value = payload.isAuthenticated; if (!payload.isAuthenticated) { userId.value = null; } }; unsubscribe = transcodes.on('AUTH_STATE_CHANGED', handleAuthChange); }); onUnmounted(() => { // Cleanup subscription if (unsubscribe) { unsubscribe(); } }); const openAuthLoginModal = async (): Promise<ApiResponse<AuthResult[]>> => { const result = await transcodes.openAuthLoginModal({ projectId, }); if (result.success && result.payload.length > 0) { userId.value = result.payload[0].user.id; } return result; }; const openAuthModal = async (): Promise<ApiResponse<null>> => { if (!userId.value) { return Promise.reject(new Error('Not authenticated')); } return transcodes.openAuthModal({ projectId, userId: userId.value, }); }; const openAuthMfaModal = async (): Promise<ApiResponse<AuthResult[]>> => { if (!userId.value) { return Promise.reject(new Error('Not authenticated')); } return transcodes.openAuthMfaModal({ projectId, userId: userId.value, }); }; const getUser = async (): Promise<ApiResponse<User[]>> => { if (!userId.value) { return Promise.reject(new Error('Not authenticated')); } return transcodes.user.get({ projectId, userId: userId.value }); }; const signOut = () => transcodes.token.signOut(); return { isAuthenticated: readonly(isAuthenticated), isLoading: readonly(isLoading), userId: readonly(userId), openAuthLoginModal, openAuthModal, openAuthMfaModal, getUser, signOut, }; }

Important: isAuthenticated() is an async method. Always use await.


Components

Login Button

src/components/LoginButton.vue
<script setup lang="ts"> import { ref } from 'vue'; import { useAuth } from '../composables/useAuth'; const { openAuthLoginModal } = useAuth(); const loading = ref(false); const handleLogin = async () => { loading.value = true; try { const result = await openAuthLoginModal(); if (result.success) { console.log('Login successful:', result.payload[0].user.email); } } catch (error) { console.error('Login error:', error); } finally { loading.value = false; } }; </script> <template> <button @click="handleLogin" :disabled="loading" class="login-btn"> {{ loading ? 'Loading...' : 'Login with Passkey' }} </button> </template> <style scoped> .login-btn { padding: 12px 24px; background: #000; color: #fff; border: none; border-radius: 8px; cursor: pointer; } .login-btn:disabled { opacity: 0.5; cursor: not-allowed; } </style>

User Profile

src/components/UserProfile.vue
<script setup lang="ts"> import { ref, watch } from 'vue'; import { useAuth } from '../composables/useAuth'; import type { User } from '../../types/transcodes'; const { isAuthenticated, getUser, signOut } = useAuth(); const user = ref<User | null>(null); // Fetch user when authenticated watch( isAuthenticated, async (authenticated) => { if (authenticated) { try { const result = await getUser(); if (result.success && result.payload.length > 0) { user.value = result.payload[0]; } } catch (error) { console.error('Failed to get user:', error); } } else { user.value = null; } }, { immediate: true } ); </script> <template> <div v-if="isAuthenticated && user" class="user-profile"> <div class="info"> <h3>{{ user.name || 'User' }}</h3> <p>{{ user.email }}</p> </div> <button @click="signOut" class="signout-btn">Sign Out</button> </div> </template> <style scoped> .user-profile { display: flex; align-items: center; gap: 16px; padding: 16px; background: #f5f5f5; border-radius: 8px; } .info h3 { margin: 0; } .info p { margin: 4px 0 0; color: #666; } .signout-btn { margin-left: auto; padding: 8px 16px; background: transparent; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; } </style>

Auth Guard Component

A component that shows login modal when user is not authenticated:

src/components/AuthGuard.vue
<script setup lang="ts"> import { watch, ref } from 'vue'; import { useRouter } from 'vue-router'; import { useAuth } from '../composables/useAuth'; const props = defineProps<{ redirectTo?: string; }>(); const { isAuthenticated, isLoading, openAuthLoginModal } = useAuth(); const router = useRouter(); const hasAttemptedLogin = ref(false); watch( [isAuthenticated, isLoading], async ([authenticated, loading]) => { if (loading || authenticated || hasAttemptedLogin.value) return; hasAttemptedLogin.value = true; try { const result = await openAuthLoginModal(); if (!result.success) { router.push(props.redirectTo || '/'); } } catch { router.push(props.redirectTo || '/'); } }, { immediate: true } ); </script> <template> <div v-if="isLoading" class="loading">Loading...</div> <slot v-else-if="isAuthenticated" /> </template> <style scoped> .loading { display: flex; justify-content: center; align-items: center; min-height: 200px; } </style>

Router Setup

Vue Router with Navigation Guards

src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router'; const routes: RouteRecordRaw[] = [ { path: '/', name: 'Home', component: () => import('../views/HomeView.vue'), }, { path: '/dashboard', name: 'Dashboard', component: () => import('../views/DashboardView.vue'), meta: { requiresAuth: true }, }, { path: '/settings', name: 'Settings', component: () => import('../views/SettingsView.vue'), meta: { requiresAuth: true }, }, ]; const router = createRouter({ history: createWebHistory(), routes, }); // Navigation Guard router.beforeEach(async (to, _from, next) => { const requiresAuth = to.matched.some((record) => record.meta.requiresAuth); if (requiresAuth) { // Check authentication status (async!) const isAuth = await transcodes.token.isAuthenticated(); if (isAuth) { next(); } else { // Store intended destination and redirect to home next({ name: 'Home', query: { redirect: to.fullPath } }); } } else { next(); } }); export default router;

Important: The navigation guard uses await transcodes.token.isAuthenticated() because it’s an async method.


App.vue Example

src/App.vue
<script setup lang="ts"> import { useAuth } from './composables/useAuth'; import LoginButton from './components/LoginButton.vue'; import UserProfile from './components/UserProfile.vue'; const { isAuthenticated, isLoading } = useAuth(); </script> <template> <div id="app"> <header> <nav> <router-link to="/">Home</router-link> <router-link to="/dashboard">Dashboard</router-link> </nav> <div class="auth-section"> <div v-if="isLoading">Loading...</div> <UserProfile v-else-if="isAuthenticated" /> <LoginButton v-else /> </div> </header> <main> <router-view /> </main> </div> </template> <style> #app { max-width: 1200px; margin: 0 auto; padding: 20px; } header { display: flex; justify-content: space-between; align-items: center; padding: 20px 0; border-bottom: 1px solid #eee; } nav { display: flex; gap: 20px; } nav a { text-decoration: none; color: #333; } nav a.router-link-active { font-weight: bold; } </style>

Protected View Example

src/views/DashboardView.vue
<script setup lang="ts"> import AuthGuard from '../components/AuthGuard.vue'; import UserProfile from '../components/UserProfile.vue'; </script> <template> <AuthGuard redirect-to="/"> <div class="dashboard"> <h1>Dashboard</h1> <UserProfile /> </div> </AuthGuard> </template>

Pinia Store (Alternative)

For applications using Pinia for state management:

src/stores/auth.ts
import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import type { ApiResponse, AuthResult, AuthStateChangedPayload, User, } from '../../types/transcodes'; const projectId = import.meta.env.VITE_TRANSCODES_PROJECT_ID; export const useAuthStore = defineStore('auth', () => { const isAuthenticated = ref(false); const isLoading = ref(true); const userId = ref<string | null>(null); const user = ref<User | null>(null); let unsubscribe: (() => void) | null = null; async function init() { // Check initial auth state isAuthenticated.value = await transcodes.token.isAuthenticated(); isLoading.value = false; // Subscribe to auth changes unsubscribe = transcodes.on( 'AUTH_STATE_CHANGED', (payload: AuthStateChangedPayload) => { isAuthenticated.value = payload.isAuthenticated; if (!payload.isAuthenticated) { userId.value = null; user.value = null; } } ); } async function login(): Promise<ApiResponse<AuthResult[]>> { const result = await transcodes.openAuthLoginModal({ projectId, }); if (result.success && result.payload.length > 0) { userId.value = result.payload[0].user.id; user.value = result.payload[0].user; } return result; } async function signOut() { await transcodes.token.signOut(); userId.value = null; user.value = null; } function cleanup() { if (unsubscribe) { unsubscribe(); } } return { isAuthenticated, isLoading, userId, user, init, login, signOut, cleanup, }; });
src/main.ts
import { createApp } from 'vue'; import { createPinia } from 'pinia'; import App from './App.vue'; import router from './router'; import { useAuthStore } from './stores/auth'; const app = createApp(App); const pinia = createPinia(); app.use(pinia); app.use(router); // Initialize auth store const authStore = useAuthStore(); authStore.init(); app.mount('#app');

API Calls with Token

For the fetchWithAuth utility, see React Integration - API Calls with Token. The implementation is framework-agnostic.


Best Practices

Recommended patterns for using Transcodes in Vue applications.

  1. Use Composables: Create reusable useAuth composable for authentication logic
  2. Reactive State: Use ref and computed for reactive auth state
  3. Navigation Guard: Use Vue Router’s beforeEach for async route protection
  4. Always Cleanup: Unsubscribe from events in onUnmounted
  5. Async Methods: Remember isAuthenticated() and getAccessToken() are async
  6. Type Safety: Use the provided TypeScript definitions

Common Mistakes

See React Integration - Common Mistakes for common pitfalls including async isAuthenticated(), event name casing, and method names.


Next Steps

Last updated on