Vue.js Integration
⚡ 20 min readIntegrate 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
localhostfor development)
Installation
Add WebWorker Script
Add the Transcodes WebWorker script to your 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
VITE_TRANSCODES_PROJECT_ID=proj_abc123xyzAdd TypeScript Type Definitions
Download the complete type definitions from the API Reference and save as types/transcodes.d.ts.
Then update your 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:
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
<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
<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:
<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
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
<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
<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:
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,
};
});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.
- Use Composables: Create reusable
useAuthcomposable for authentication logic - Reactive State: Use
refandcomputedfor reactive auth state - Navigation Guard: Use Vue Router’s
beforeEachfor async route protection - Always Cleanup: Unsubscribe from events in
onUnmounted - Async Methods: Remember
isAuthenticated()andgetAccessToken()are async - 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
- Vanilla JS Integration - Pure JavaScript guide
- API Reference - Full API documentation
- Security Best Practices - Security guidelines