InterviewRelay

React Integration Example#

Integrate InterviewRelay AI interviews into your React application with our SDK. This guide covers everything from basic setup to advanced patterns with TypeScript, React Router, and Next.js.


Installation#

Install the InterviewRelay React SDK:

npm install @interviewrelay/react-sdk

Or with yarn:

yarn add @interviewrelay/react-sdk

Or with pnpm:

pnpm add @interviewrelay/react-sdk

Complete Example Component#

Here's a fully-featured interview component with TypeScript, error handling, and loading states:

import React, { useCallback, useState } from 'react';
import { InterviewWidget, InterviewResult, InterviewError } from '@interviewrelay/react-sdk';

interface InterviewPageProps {
  inviteToken: string;
  onInterviewComplete?: (result: InterviewResult) => void;
}

export function InterviewPage({ inviteToken, onInterviewComplete }: InterviewPageProps) {
  const [status, setStatus] = useState<'loading' | 'ready' | 'active' | 'completed' | 'error'>('loading');
  const [error, setError] = useState<string | null>(null);
  const [result, setResult] = useState<InterviewResult | null>(null);

  const handleReady = useCallback(() => {
    setStatus('ready');
  }, []);

  const handleStart = useCallback(() => {
    setStatus('active');
  }, []);

  const handleComplete = useCallback((result: InterviewResult) => {
    setStatus('completed');
    setResult(result);
    onInterviewComplete?.(result);
  }, [onInterviewComplete]);

  const handleError = useCallback((error: InterviewError) => {
    setStatus('error');
    setError(error.message);
    console.error('Interview error:', error);
  }, []);

  if (status === 'completed' && result) {
    return (
      <div className="flex flex-col items-center justify-center min-h-[500px] text-center p-8">
        <div className="text-green-500 text-5xl mb-4">✓</div>
        <h2 className="text-2xl font-bold mb-2">Thank You!</h2>
        <p className="text-gray-400 mb-4">Your interview has been recorded successfully.</p>
        <div className="text-sm text-gray-500">
          <p>Duration: {Math.round(result.duration / 60)} minutes</p>
          <p>Questions answered: {result.questionsAnswered}</p>
        </div>
      </div>
    );
  }

  if (status === 'error') {
    return (
      <div className="flex flex-col items-center justify-center min-h-[500px] text-center p-8">
        <div className="text-red-500 text-5xl mb-4">✕</div>
        <h2 className="text-2xl font-bold mb-2">Something Went Wrong</h2>
        <p className="text-gray-400 mb-4">{error}</p>
        <button
          onClick={() => window.location.reload()}
          className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
        >
          Try Again
        </button>
      </div>
    );
  }

  return (
    <div className="w-full max-w-4xl mx-auto">
      {status === 'loading' && (
        <div className="flex items-center justify-center min-h-[500px]">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500" />
        </div>
      )}

      <InterviewWidget
        inviteToken={inviteToken}
        theme={{
          primaryColor: '#6366f1',
          backgroundColor: '#111111',
          textColor: '#ffffff',
          borderRadius: '12px',
        }}
        onReady={handleReady}
        onStart={handleStart}
        onComplete={handleComplete}
        onError={handleError}
      />
    </div>
  );
}

Usage in Your App#

import React from 'react';
import { InterviewPage } from './components/InterviewPage';

function App() {
  const handleComplete = (result) => {
    console.log('Interview completed:', result);
    // Send to your analytics, redirect, etc.
  };

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

      <main className="p-8">
        <InterviewPage
          inviteToken="inv_abc123xyz"
          onInterviewComplete={handleComplete}
        />
      </main>
    </div>
  );
}

export default App;

With React Router#

Use React Router to create a dedicated interview route with the token in the URL:

import React from 'react';
import { BrowserRouter, Routes, Route, useSearchParams, Navigate } from 'react-router-dom';
import { InterviewPage } from './components/InterviewPage';

function InterviewRoute() {
  const [searchParams] = useSearchParams();
  const token = searchParams.get('token');

  if (!token) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="text-center">
          <h2 className="text-2xl font-bold text-red-400 mb-2">Missing Token</h2>
          <p className="text-gray-400">
            No invite token found. Please use the link from your invitation email.
          </p>
        </div>
      </div>
    );
  }

  return <InterviewPage inviteToken={token} />;
}

function ThankYouPage() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="text-center">
        <h2 className="text-2xl font-bold text-green-400 mb-2">Thank You!</h2>
        <p className="text-gray-400">Your interview has been submitted.</p>
      </div>
    </div>
  );
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/interview" element={<InterviewRoute />} />
        <Route path="/thank-you" element={<ThankYouPage />} />
        <Route path="*" element={<Navigate to="/interview" />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

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


With Next.js#

App Router (Next.js 13+)#

Create a client component for the interview widget:

// app/interview/page.tsx
'use client';

import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { InterviewPage } from '@/components/InterviewPage';

function InterviewContent() {
  const searchParams = useSearchParams();
  const token = searchParams.get('token');

  if (!token) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <p className="text-red-400">No invite token provided.</p>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-950 text-white p-8">
      <InterviewPage inviteToken={token} />
    </div>
  );
}

export default function InterviewPage() {
  return (
    <Suspense fallback={<div className="flex items-center justify-center min-h-screen">Loading...</div>}>
      <InterviewContent />
    </Suspense>
  );
}

Server-Side Token Generation (Next.js API Route)#

Generate invite tokens securely on the server:

// app/api/create-invite/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const { email, campaignId } = await request.json();

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

  if (!response.ok) {
    return NextResponse.json(
      { error: 'Failed to create invite' },
      { status: response.status }
    );
  }

  const data = await response.json();

  return NextResponse.json({
    invite_token: data.invite_token,
    expires_at: data.expires_at,
  });
}

Client Component Required

The InterviewWidget component uses browser APIs (DOM, WebRTC) and must be rendered as a client component. Always add 'use client' at the top of the file or wrap it in a client component boundary.


Fetching Tokens Dynamically#

Fetch invite tokens from your backend on demand:

import React, { useEffect, useState } from 'react';
import { InterviewPage } from './components/InterviewPage';

interface DynamicInterviewProps {
  participantEmail: string;
  campaignId: string;
}

export function DynamicInterview({ participantEmail, campaignId }: DynamicInterviewProps) {
  const [token, setToken] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

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

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

        const data = await response.json();
        setToken(data.invite_token);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error');
      } finally {
        setLoading(false);
      }
    }

    fetchToken();
  }, [participantEmail, campaignId]);

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-[500px]">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500" />
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex items-center justify-center min-h-[500px]">
        <p className="text-red-400">{error}</p>
      </div>
    );
  }

  if (!token) {
    return null;
  }

  return <InterviewPage inviteToken={token} />;
}

TypeScript Types#

The SDK ships with full TypeScript definitions. Here are the key types:

import type {
  InterviewResult,
  InterviewError,
  InterviewTheme,
  InterviewWidgetProps,
  InterviewStatus,
} from '@interviewrelay/react-sdk';

// Interview result returned on completion
interface InterviewResult {
  sessionId: string;
  duration: number;           // Duration in seconds
  questionsAnswered: number;
  questionsTotal: number;
  completionStatus: 'completed' | 'timed_out' | 'abandoned';
}

// Error object passed to onError callback
interface InterviewError {
  code: string;               // e.g., 'NETWORK_ERROR', 'TOKEN_EXPIRED'
  message: string;            // Human-readable error message
  details?: Record<string, unknown>;
}

// Theme customization options
interface InterviewTheme {
  primaryColor?: string;
  backgroundColor?: string;
  textColor?: string;
  secondaryTextColor?: string;
  borderRadius?: string;
  fontFamily?: string;
}

// Widget component props
interface InterviewWidgetProps {
  inviteToken: string;
  theme?: InterviewTheme;
  locale?: string;
  autoStart?: boolean;
  onReady?: () => void;
  onStart?: () => void;
  onComplete?: (result: InterviewResult) => void;
  onError?: (error: InterviewError) => void;
}

Custom Hooks#

useInterview#

Create a reusable hook to manage interview state:

import { useState, useCallback } from 'react';
import type { InterviewResult, InterviewError } from '@interviewrelay/react-sdk';

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

interface UseInterviewReturn {
  status: InterviewStatus;
  result: InterviewResult | null;
  error: string | null;
  handlers: {
    onReady: () => void;
    onStart: () => void;
    onComplete: (result: InterviewResult) => void;
    onError: (error: InterviewError) => void;
  };
  reset: () => void;
}

export function useInterview(): UseInterviewReturn {
  const [status, setStatus] = useState<InterviewStatus>('idle');
  const [result, setResult] = useState<InterviewResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  const onReady = useCallback(() => {
    setStatus('ready');
  }, []);

  const onStart = useCallback(() => {
    setStatus('active');
  }, []);

  const onComplete = useCallback((result: InterviewResult) => {
    setStatus('completed');
    setResult(result);
  }, []);

  const onError = useCallback((err: InterviewError) => {
    setStatus('error');
    setError(err.message);
  }, []);

  const reset = useCallback(() => {
    setStatus('idle');
    setResult(null);
    setError(null);
  }, []);

  return {
    status,
    result,
    error,
    handlers: { onReady, onStart, onComplete, onError },
    reset,
  };
}

Usage with the hook:

import { InterviewWidget } from '@interviewrelay/react-sdk';
import { useInterview } from './hooks/useInterview';

function Interview({ token }: { token: string }) {
  const { status, result, error, handlers, reset } = useInterview();

  if (status === 'completed') {
    return (
      <div>
        <h2>Interview Complete</h2>
        <p>Duration: {Math.round(result!.duration / 60)} min</p>
        <button onClick={reset}>Start Over</button>
      </div>
    );
  }

  if (status === 'error') {
    return (
      <div>
        <p className="text-red-500">{error}</p>
        <button onClick={reset}>Retry</button>
      </div>
    );
  }

  return (
    <InterviewWidget
      inviteToken={token}
      {...handlers}
    />
  );
}

Best Practices#

Error Boundaries#

Wrap the interview widget in an error boundary to catch unexpected rendering errors:

import React, { Component, ErrorInfo, ReactNode } from 'react';

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback?: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

class InterviewErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('Interview widget error:', error, errorInfo);
    // Report to your error tracking service
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="p-8 text-center">
          <h3 className="text-red-400 font-bold">Interview Widget Error</h3>
          <p className="text-gray-400 mt-2">
            Something went wrong loading the interview.
          </p>
          <button
            onClick={() => this.setState({ hasError: false, error: null })}
            className="mt-4 px-4 py-2 bg-indigo-600 rounded-lg"
          >
            Try Again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// Usage
function App() {
  return (
    <InterviewErrorBoundary>
      <InterviewPage inviteToken="inv_abc123" />
    </InterviewErrorBoundary>
  );
}

Loading States#

Always show a loading state while the widget initializes:

function InterviewWithLoader({ token }: { token: string }) {
  const [isReady, setIsReady] = useState(false);

  return (
    <div className="relative">
      {!isReady && (
        <div className="absolute inset-0 flex items-center justify-center bg-gray-950 z-10">
          <div className="text-center">
            <div className="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-500 mx-auto mb-4" />
            <p className="text-gray-400">Preparing your interview...</p>
          </div>
        </div>
      )}

      <InterviewWidget
        inviteToken={token}
        onReady={() => setIsReady(true)}
      />
    </div>
  );
}

Cleanup#

The InterviewWidget component handles its own cleanup when unmounted. However, if you need to programmatically destroy the widget:

import { useRef } from 'react';
import { InterviewWidget, InterviewWidgetRef } from '@interviewrelay/react-sdk';

function Interview({ token }: { token: string }) {
  const widgetRef = useRef<InterviewWidgetRef>(null);

  const handleCancel = () => {
    // Gracefully stop the interview
    widgetRef.current?.destroy();
  };

  return (
    <div>
      <InterviewWidget ref={widgetRef} inviteToken={token} />
      <button onClick={handleCancel}>Cancel Interview</button>
    </div>
  );
}

Automatic Cleanup

You don't need to manually clean up in most cases. The InterviewWidget component automatically disconnects WebRTC connections, stops media streams, and releases resources when the component unmounts.


Next Steps#