InterviewRelay

Vue Integration Example#

Integrate InterviewRelay AI interviews into your Vue.js application. This guide covers Vue 3 (Composition API), Vue 2 (Options API), Vue Router, Nuxt 3, and reusable composables.


Installation#

Install the InterviewRelay Vue SDK:

npm install @interviewrelay/vue-sdk

Or with yarn:

yarn add @interviewrelay/vue-sdk

Or with pnpm:

pnpm add @interviewrelay/vue-sdk

Vue 3 Composition API#

Here's a complete interview component using Vue 3's Composition API with TypeScript:

<script setup lang="ts">
import { ref, computed } from 'vue';
import { InterviewWidget } from '@interviewrelay/vue-sdk';
import type { InterviewResult, InterviewError } from '@interviewrelay/vue-sdk';

const props = defineProps<{
  inviteToken: string;
}>();

const emit = defineEmits<{
  complete: [result: InterviewResult];
  error: [error: InterviewError];
}>();

const status = ref<'loading' | 'ready' | 'active' | 'completed' | 'error'>('loading');
const result = ref<InterviewResult | null>(null);
const errorMessage = ref<string | null>(null);

const isCompleted = computed(() => status.value === 'completed');
const hasError = computed(() => status.value === 'error');
const isLoading = computed(() => status.value === 'loading');

const theme = {
  primaryColor: '#6366f1',
  backgroundColor: '#111111',
  textColor: '#ffffff',
  borderRadius: '12px',
};

function onReady() {
  status.value = 'ready';
}

function onStart() {
  status.value = 'active';
}

function onComplete(interviewResult: InterviewResult) {
  status.value = 'completed';
  result.value = interviewResult;
  emit('complete', interviewResult);
}

function onError(error: InterviewError) {
  status.value = 'error';
  errorMessage.value = error.message;
  emit('error', error);
  console.error('Interview error:', error);
}

function retry() {
  window.location.reload();
}
</script>

<template>
  <div class="interview-wrapper">
    <!-- Completed State -->
    <div v-if="isCompleted" class="status-screen success">
      <div class="icon">✓</div>
      <h2>Thank You!</h2>
      <p>Your interview has been recorded successfully.</p>
      <div v-if="result" class="details">
        <p>Duration: {{ Math.round(result.duration / 60) }} minutes</p>
        <p>Questions answered: {{ result.questionsAnswered }}</p>
      </div>
    </div>

    <!-- Error State -->
    <div v-else-if="hasError" class="status-screen error">
      <div class="icon">✕</div>
      <h2>Something Went Wrong</h2>
      <p>{{ errorMessage }}</p>
      <button @click="retry" class="retry-button">Try Again</button>
    </div>

    <!-- Interview Widget -->
    <template v-else>
      <div v-if="isLoading" class="loading-overlay">
        <div class="spinner" />
        <p>Preparing your interview...</p>
      </div>

      <InterviewWidget
        :invite-token="props.inviteToken"
        :theme="theme"
        @ready="onReady"
        @start="onStart"
        @complete="onComplete"
        @error="onError"
      />
    </template>
  </div>
</template>

<style scoped>
.interview-wrapper {
  width: 100%;
  max-width: 800px;
  margin: 0 auto;
  min-height: 500px;
  position: relative;
}

.status-screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 500px;
  text-align: center;
  padding: 2rem;
}

.status-screen .icon {
  font-size: 3rem;
  margin-bottom: 1rem;
}

.status-screen.success .icon {
  color: #22c55e;
}

.status-screen.error .icon {
  color: #ef4444;
}

.status-screen h2 {
  font-size: 1.5rem;
  font-weight: 700;
  margin-bottom: 0.5rem;
}

.status-screen p {
  color: #888;
  margin-bottom: 0.25rem;
}

.details {
  margin-top: 1rem;
  font-size: 0.875rem;
  color: #666;
}

.retry-button {
  margin-top: 1rem;
  padding: 0.5rem 1.5rem;
  background: #6366f1;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-size: 0.875rem;
}

.retry-button:hover {
  background: #4f46e5;
}

.loading-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: #0a0a0a;
  z-index: 10;
}

.spinner {
  width: 2rem;
  height: 2rem;
  border: 2px solid transparent;
  border-bottom-color: #6366f1;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  margin-bottom: 1rem;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}
</style>

Vue 2 Options API#

If you're using Vue 2, here's the equivalent component with the Options API:

<template>
  <div class="interview-wrapper">
    <!-- Completed State -->
    <div v-if="status === 'completed'" class="status-screen success">
      <div class="icon">✓</div>
      <h2>Thank You!</h2>
      <p>Your interview has been recorded successfully.</p>
      <div v-if="result" class="details">
        <p>Duration: {{ Math.round(result.duration / 60) }} minutes</p>
        <p>Questions answered: {{ result.questionsAnswered }}</p>
      </div>
    </div>

    <!-- Error State -->
    <div v-else-if="status === 'error'" class="status-screen error">
      <div class="icon">✕</div>
      <h2>Something Went Wrong</h2>
      <p>{{ errorMessage }}</p>
      <button @click="retry" class="retry-button">Try Again</button>
    </div>

    <!-- Interview Widget -->
    <template v-else>
      <div v-if="status === 'loading'" class="loading-overlay">
        <div class="spinner" />
        <p>Preparing your interview...</p>
      </div>

      <InterviewWidget
        :invite-token="inviteToken"
        :theme="theme"
        @ready="onReady"
        @start="onStart"
        @complete="onComplete"
        @error="onError"
      />
    </template>
  </div>
</template>

<script>
import { InterviewWidget } from '@interviewrelay/vue-sdk';

export default {
  name: 'InterviewPage',

  components: {
    InterviewWidget,
  },

  props: {
    inviteToken: {
      type: String,
      required: true,
    },
  },

  data() {
    return {
      status: 'loading',
      result: null,
      errorMessage: null,
      theme: {
        primaryColor: '#6366f1',
        backgroundColor: '#111111',
        textColor: '#ffffff',
        borderRadius: '12px',
      },
    };
  },

  methods: {
    onReady() {
      this.status = 'ready';
    },

    onStart() {
      this.status = 'active';
    },

    onComplete(result) {
      this.status = 'completed';
      this.result = result;
      this.$emit('complete', result);
    },

    onError(error) {
      this.status = 'error';
      this.errorMessage = error.message;
      this.$emit('error', error);
      console.error('Interview error:', error);
    },

    retry() {
      window.location.reload();
    },
  },
};
</script>

Vue 2 Support

The @interviewrelay/vue-sdk package supports both Vue 2 and Vue 3. Vue 2 users should use version ^1.x, while Vue 3 users should use ^2.x.


Usage in Parent Component#

Vue 3#

<script setup lang="ts">
import InterviewPage from './components/InterviewPage.vue';
import type { InterviewResult } from '@interviewrelay/vue-sdk';

function handleComplete(result: InterviewResult) {
  console.log('Interview completed:', result);
  // Navigate, track analytics, etc.
}
</script>

<template>
  <div class="min-h-screen bg-gray-950 text-white">
    <header class="p-4 border-b border-gray-800">
      <h1 class="text-xl font-semibold">Your Company</h1>
    </header>

    <main class="p-8">
      <InterviewPage
        invite-token="inv_abc123xyz"
        @complete="handleComplete"
      />
    </main>
  </div>
</template>

Vue 2#

<template>
  <div class="app">
    <header>
      <h1>Your Company</h1>
    </header>

    <main>
      <InterviewPage
        :invite-token="token"
        @complete="handleComplete"
      />
    </main>
  </div>
</template>

<script>
import InterviewPage from './components/InterviewPage.vue';

export default {
  components: { InterviewPage },

  data() {
    return {
      token: 'inv_abc123xyz',
    };
  },

  methods: {
    handleComplete(result) {
      console.log('Interview completed:', result);
    },
  },
};
</script>

With Vue Router#

Set up a dedicated interview route with the token as a query parameter:

Vue 3 + Vue Router 4#

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/interview',
      name: 'interview',
      component: () => import('../views/InterviewView.vue'),
    },
    {
      path: '/thank-you',
      name: 'thank-you',
      component: () => import('../views/ThankYouView.vue'),
    },
  ],
});

export default router;
<!-- views/InterviewView.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import InterviewPage from '../components/InterviewPage.vue';
import type { InterviewResult } from '@interviewrelay/vue-sdk';

const route = useRoute();
const router = useRouter();

const token = computed(() => route.query.token as string | undefined);

function handleComplete(result: InterviewResult) {
  router.push({ name: 'thank-you', query: { session: result.sessionId } });
}
</script>

<template>
  <div class="interview-view">
    <div v-if="!token" class="no-token">
      <h2>Missing Token</h2>
      <p>No invite token found. Please use the link from your invitation email.</p>
    </div>

    <InterviewPage
      v-else
      :invite-token="token"
      @complete="handleComplete"
    />
  </div>
</template>

<style scoped>
.interview-view {
  min-height: 100vh;
  background: #0a0a0a;
  color: #ffffff;
  padding: 2rem;
}

.no-token {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 80vh;
  text-align: center;
}

.no-token h2 {
  color: #ef4444;
  margin-bottom: 0.5rem;
}

.no-token p {
  color: #888;
}
</style>

Invite links would look like: https://yourapp.com/interview?token=inv_abc123xyz


Fetching Tokens Dynamically#

Fetch invite tokens from your backend on demand:

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import InterviewPage from './components/InterviewPage.vue';

const props = defineProps<{
  participantEmail: string;
  campaignId: string;
}>();

const token = ref<string | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);

onMounted(async () => {
  try {
    const response = await fetch('/api/create-invite', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email: props.participantEmail,
        campaignId: props.campaignId,
      }),
    });

    if (!response.ok) {
      throw new Error('Failed to create invite');
    }

    const data = await response.json();
    token.value = data.invite_token;
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'Unknown error';
  } finally {
    loading.value = false;
  }
});
</script>

<template>
  <div>
    <!-- Loading -->
    <div v-if="loading" class="loading">
      <div class="spinner" />
      <p>Creating your interview session...</p>
    </div>

    <!-- Error -->
    <div v-else-if="error" class="error">
      <p>{{ error }}</p>
    </div>

    <!-- Interview -->
    <InterviewPage
      v-else-if="token"
      :invite-token="token"
    />
  </div>
</template>

Nuxt 3 Integration#

Plugin Setup#

Register the InterviewRelay SDK as a Nuxt plugin:

// plugins/interviewrelay.client.ts
import { InterviewWidget } from '@interviewrelay/vue-sdk';

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.component('InterviewWidget', InterviewWidget);
});

Client-Side Only

The InterviewRelay widget requires browser APIs (DOM, WebRTC). Name the plugin file with .client.ts to ensure it only runs on the client side.

Page Component#

<!-- pages/interview.vue -->
<script setup lang="ts">
import type { InterviewResult, InterviewError } from '@interviewrelay/vue-sdk';

const route = useRoute();
const router = useRouter();

const token = computed(() => route.query.token as string | undefined);
const status = ref<'loading' | 'ready' | 'active' | 'completed' | 'error'>('loading');
const errorMessage = ref<string | null>(null);

function onReady() {
  status.value = 'ready';
}

function onStart() {
  status.value = 'active';
}

function onComplete(result: InterviewResult) {
  status.value = 'completed';
  router.push(`/thank-you?session=${result.sessionId}`);
}

function onError(error: InterviewError) {
  status.value = 'error';
  errorMessage.value = error.message;
}

useHead({
  title: 'Your Interview - InterviewRelay',
});
</script>

<template>
  <div class="min-h-screen bg-gray-950 text-white p-8">
    <div v-if="!token" class="flex items-center justify-center min-h-[80vh]">
      <div class="text-center">
        <h2 class="text-2xl font-bold text-red-400 mb-2">Missing Token</h2>
        <p class="text-gray-400">No invite token provided.</p>
      </div>
    </div>

    <ClientOnly v-else>
      <InterviewWidget
        :invite-token="token"
        :theme="{
          primaryColor: '#6366f1',
          backgroundColor: '#111111',
          textColor: '#ffffff',
        }"
        @ready="onReady"
        @start="onStart"
        @complete="onComplete"
        @error="onError"
      />

      <template #fallback>
        <div class="flex items-center justify-center min-h-[500px]">
          <p class="text-gray-400">Loading interview widget...</p>
        </div>
      </template>
    </ClientOnly>
  </div>
</template>

Server API Route#

Generate tokens server-side in Nuxt:

// server/api/create-invite.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const config = useRuntimeConfig();

  const response = await $fetch('https://api.interviewrelay.com/v1/invites', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${config.interviewrelayApiKey}`,
      'Content-Type': 'application/json',
    },
    body: {
      campaign_id: body.campaignId,
      participant_email: body.email,
    },
  });

  return {
    invite_token: response.invite_token,
    expires_at: response.expires_at,
  };
});
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    interviewrelayApiKey: process.env.INTERVIEWRELAY_API_KEY,
  },
});

Composable Hook#

useInterview#

Create a reusable composable to manage interview state:

// composables/useInterview.ts
import { ref, computed } from 'vue';
import type { InterviewResult, InterviewError } from '@interviewrelay/vue-sdk';

type InterviewStatus = 'idle' | 'loading' | 'ready' | 'active' | 'completed' | 'error';

export function useInterview() {
  const status = ref<InterviewStatus>('idle');
  const result = ref<InterviewResult | null>(null);
  const error = ref<string | null>(null);

  const isLoading = computed(() => status.value === 'loading' || status.value === 'idle');
  const isActive = computed(() => status.value === 'active');
  const isCompleted = computed(() => status.value === 'completed');
  const hasError = computed(() => status.value === 'error');

  function onReady() {
    status.value = 'ready';
  }

  function onStart() {
    status.value = 'active';
  }

  function onComplete(interviewResult: InterviewResult) {
    status.value = 'completed';
    result.value = interviewResult;
  }

  function onError(interviewError: InterviewError) {
    status.value = 'error';
    error.value = interviewError.message;
  }

  function reset() {
    status.value = 'idle';
    result.value = null;
    error.value = null;
  }

  return {
    // State
    status,
    result,
    error,

    // Computed
    isLoading,
    isActive,
    isCompleted,
    hasError,

    // Event handlers
    handlers: {
      onReady,
      onStart,
      onComplete,
      onError,
    },

    // Actions
    reset,
  };
}

Usage with the composable:

<script setup lang="ts">
import { InterviewWidget } from '@interviewrelay/vue-sdk';
import { useInterview } from '../composables/useInterview';

const props = defineProps<{ token: string }>();

const { status, result, error, isCompleted, hasError, isLoading, handlers, reset } = useInterview();
</script>

<template>
  <div>
    <div v-if="isCompleted" class="text-center py-16">
      <h2 class="text-2xl font-bold text-green-400">Interview Complete</h2>
      <p class="text-gray-400 mt-2">
        Duration: {{ Math.round(result!.duration / 60) }} min
      </p>
      <button @click="reset" class="mt-4 px-4 py-2 bg-indigo-600 rounded-lg">
        Start Over
      </button>
    </div>

    <div v-else-if="hasError" class="text-center py-16">
      <p class="text-red-400">{{ error }}</p>
      <button @click="reset" class="mt-4 px-4 py-2 bg-indigo-600 rounded-lg">
        Retry
      </button>
    </div>

    <div v-else class="relative">
      <div v-if="isLoading" class="absolute inset-0 flex items-center justify-center bg-gray-950 z-10">
        <div class="spinner" />
      </div>

      <InterviewWidget
        :invite-token="props.token"
        v-on="handlers"
      />
    </div>
  </div>
</template>

Best Practices#

Error Handling#

Always handle errors gracefully. The SDK can emit errors for network issues, expired tokens, and browser incompatibilities:

<script setup lang="ts">
import type { InterviewError } from '@interviewrelay/vue-sdk';

function onError(error: InterviewError) {
  switch (error.code) {
    case 'NETWORK_ERROR':
      // Show reconnection UI
      break;
    case 'TOKEN_EXPIRED':
      // Redirect to get a new token
      break;
    case 'TOKEN_INVALID':
      // Show invalid link message
      break;
    case 'BROWSER_UNSUPPORTED':
      // Show browser upgrade message
      break;
    default:
      // Generic error handling
      console.error('Interview error:', error);
  }
}
</script>

Loading States#

Show meaningful loading indicators while the widget initializes:

<template>
  <div class="relative min-h-[500px]">
    <Transition name="fade">
      <div v-if="!isReady" class="absolute inset-0 flex flex-col items-center justify-center bg-gray-950 z-10">
        <div class="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-500 mb-4" />
        <p class="text-gray-400 text-sm">Preparing your interview...</p>
        <p class="text-gray-600 text-xs mt-1">This may take a few seconds</p>
      </div>
    </Transition>

    <InterviewWidget
      :invite-token="token"
      @ready="isReady = true"
    />
  </div>
</template>

<style scoped>
.fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-leave-to {
  opacity: 0;
}
</style>

TypeScript Support#

The SDK ships with full TypeScript declarations. Enable strict typing in your components:

// types/interview.ts
import type { InterviewResult, InterviewError, InterviewTheme } from '@interviewrelay/vue-sdk';

export interface InterviewPageProps {
  inviteToken: string;
  theme?: InterviewTheme;
  locale?: string;
  autoStart?: boolean;
}

export interface InterviewPageEmits {
  complete: [result: InterviewResult];
  error: [error: InterviewError];
  start: [];
  ready: [];
}
<script setup lang="ts">
import type { InterviewPageProps, InterviewPageEmits } from '../types/interview';

const props = defineProps<InterviewPageProps>();
const emit = defineEmits<InterviewPageEmits>();
</script>

Full Type Safety

Using TypeScript with the InterviewRelay Vue SDK gives you autocompletion for all props, events, and callback payloads in your IDE.


Next Steps#