React Integration Guide

Build SCORM functionality into your React application using the AllureLMS SCORM API.

Table of Contents

Overview

This guide shows how to integrate SCORM API into React applications, including:

  • Package upload components
  • SCORM player embedding
  • Progress tracking
  • Completion handling

Setup

Security: never expose CONNECT_API_KEY in client code. In a React/Next.js app, any process.env.VAR reference in a client component is only substituted at build time when the name starts with NEXT_PUBLIC_. Bundling CONNECT_API_KEY would either leak the secret to every visitor (Next.js NEXT_PUBLIC_* convention) or silently resolve to undefined (plain CRA). In both cases it's wrong. This guide routes all API calls through a server-side proxy that holds the key.

Install Dependencies

npm install axios @tanstack/react-query
# or
yarn add axios @tanstack/react-query

Environment Variables

Split env vars by who reads them. Server-only secrets have no NEXT_PUBLIC_ prefix and are only read by API routes / server components. Public values (tenant ID, public base URL) may be exposed to the client.

.env.local:

# Server-only — never exposed to the browser.
CONNECT_API_KEY=sk_live_your-api-key-here
CONNECT_API_BASE_URL=https://app.allureconnect.com

# Safe to expose to the client.
NEXT_PUBLIC_CONNECT_TENANT_ID=tenant_your-workspace-slug

Server-side proxy route

Create a thin Next.js API route that accepts a path + payload from your React code, attaches the API key server-side, and forwards to the Connect API. This keeps the secret in the server process.

// app/api/scorm/[...path]/route.ts  (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server';

const BASE = process.env.CONNECT_API_BASE_URL!;
const KEY = process.env.CONNECT_API_KEY!;

async function forward(req: NextRequest, { params }: { params: { path: string[] } }) {
  const url = `${BASE}/api/v1/${params.path.join('/')}${req.nextUrl.search}`;
  const init: RequestInit = {
    method: req.method,
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': KEY,
    },
  };
  if (req.method !== 'GET' && req.method !== 'HEAD') {
    init.body = await req.text();
  }
  const upstream = await fetch(url, init);
  const body = await upstream.text();
  return new NextResponse(body, {
    status: upstream.status,
    headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
  });
}

export { forward as GET, forward as POST, forward as PATCH, forward as DELETE };

In Express/Fastify/Remix/etc., the equivalent is a single authenticated passthrough route. The rule is the same: the API key only lives in code paths that run on the server.

API Client Hook

Custom Hook for API Calls

The client talks to /api/scorm/* (your proxy), not directly to app.allureconnect.com. No API key in the browser.

// hooks/useScormAPI.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';

// Same-origin proxy — inherits the user's session cookies; the proxy attaches CONNECT_API_KEY.
const apiClient = axios.create({
  baseURL: '/api/scorm',
});

export function useScormAPI() {
  const queryClient = useQueryClient();
  const tenantId = process.env.NEXT_PUBLIC_CONNECT_TENANT_ID!;

  // Upload package (presigned flow — the mint + process calls go through the proxy)
  const uploadPackage = useMutation(
    async ({ file, uploadedBy }: { file: File; uploadedBy: string }) => {
      const mint = await apiClient.post('/packages/upload-url', {
        tenant_id: tenantId,
        uploaded_by: uploadedBy,
        filename: file.name,
        file_size: file.size,
      });
      const ticket = mint.data;
      const uploadResp = await fetch(ticket.presigned_url, {
        method: 'PUT',
        headers: ticket.required_put_headers,
        body: file,
      });
      if (!uploadResp.ok) {
        throw new Error(
          `Storage PUT failed: ${uploadResp.status} ${await uploadResp.text()}`
        );
      }
      const proc = await apiClient.post('/packages/process', {
        tenant_id: tenantId,
        uploaded_by: uploadedBy,
        storage_path: ticket.storage_path,
        original_filename: file.name,
      });
      return proc.data;
    },
    {
      onSuccess: () => {
        queryClient.invalidateQueries('packages');
      },
    }
  );

  // List packages
  const { data: packages, isLoading: packagesLoading } = useQuery(
    'packages',
    async () => {
      const response = await apiClient.get(`/packages?tenant_id=${tenantId}`);
      return response.data;
    }
  );

  // Launch session
  const launchSession = useMutation(
    async ({ packageId, userId }: { packageId: string; userId: string }) => {
      const response = await apiClient.post(
        `/packages/${packageId}/launch`,
        {
          user_id: userId,
          session_id: crypto.randomUUID(),
        }
      );
      return response.data;
    }
  );

  // Get session
  const getSession = useQuery(
    ['session', launchSession.data?.session_id],
    async () => {
      if (!launchSession.data?.session_id) return null;
      const response = await apiClient.get(
        `/sessions/${launchSession.data.session_id}`
      );
      return response.data;
    },
    {
      enabled: !!launchSession.data?.session_id,
      refetchInterval: 5000, // Poll every 5 seconds
    }
  );

  return {
    uploadPackage,
    packages,
    packagesLoading,
    launchSession,
    getSession,
  };
}

Components

Package Upload Component

// components/ScormUpload.tsx
import { useState } from 'react';
import { useScormAPI } from '../hooks/useScormAPI';

export function ScormUpload({ userId }: { userId: string }) {
  const { uploadPackage } = useScormAPI();
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!file) return;

    setUploading(true);
    try {
      await uploadPackage.mutateAsync({ file, uploadedBy: userId });
      alert('Package uploaded successfully!');
      setFile(null);
    } catch (error) {
      console.error('Upload failed:', error);
      alert('Upload failed. Please try again.');
    } finally {
      setUploading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="scorm-upload">
      <input
        type="file"
        accept=".zip"
        onChange={(e) => setFile(e.target.files?.[0] || null)}
        disabled={uploading}
      />
      <button type="submit" disabled={!file || uploading}>
        {uploading ? 'Uploading...' : 'Upload Package'}
      </button>
      {uploadPackage.isError && (
        <div className="error">
          Upload failed. Please check your file and try again.
        </div>
      )}
    </form>
  );
}

Package List Component

// components/PackageList.tsx
import { useScormAPI } from '../hooks/useScormAPI';

export function PackageList() {
  const { packages, packagesLoading } = useScormAPI();

  if (packagesLoading) {
    return <div>Loading packages...</div>;
  }

  return (
    <div className="package-list">
      <h2>SCORM Packages</h2>
      {packages?.packages?.map((pkg: any) => (
        <div key={pkg.id} className="package-item">
          <h3>{pkg.title}</h3>
          <p>Version: {pkg.version}</p>
          <p>Created: {new Date(pkg.created_at).toLocaleDateString()}</p>
        </div>
      ))}
    </div>
  );
}

SCORM Player Component

// components/ScormPlayer.tsx
import { useEffect, useState } from 'react';
import { useScormAPI } from '../hooks/useScormAPI';

interface ScormPlayerProps {
  packageId: string;
  userId: string;
  onComplete?: (result: any) => void;
}

export function ScormPlayer({
  packageId,
  userId,
  onComplete,
}: ScormPlayerProps) {
  const { launchSession, getSession } = useScormAPI();
  const [playerUrl, setPlayerUrl] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function initialize() {
      try {
        const launch = await launchSession.mutateAsync({
          packageId,
          userId,
        });
        setPlayerUrl(launch.launch_url);
      } catch (error) {
        console.error('Launch failed:', error);
      } finally {
        setLoading(false);
      }
    }

    initialize();
  }, [packageId, userId]);

  // Monitor completion
  useEffect(() => {
    if (getSession.data?.completion_status === 'completed') {
      onComplete?.(getSession.data);
    }
  }, [getSession.data, onComplete]);

  if (loading) {
    return <div>Loading course...</div>;
  }

  if (!playerUrl) {
    return <div>Failed to load course</div>;
  }

  return (
    <div className="scorm-player-container">
      <iframe
        src={playerUrl}
        width="100%"
        height="800px"
        frameBorder="0"
        allow="fullscreen"
        title="SCORM Course"
      />
      {getSession.data && (
        <div className="progress-indicator">
          <p>Status: {getSession.data.completion_status}</p>
          {getSession.data.score && (
            <p>Score: {(getSession.data.score.scaled * 100).toFixed(1)}%</p>
          )}
        </div>
      )}
    </div>
  );
}

Progress Tracker Component

// components/ProgressTracker.tsx
import { useEffect } from 'react';
import { useScormAPI } from '../hooks/useScormAPI';

export function ProgressTracker({ sessionId }: { sessionId: string }) {
  const { getSession } = useScormAPI();

  if (!getSession.data) {
    return <div>Loading progress...</div>;
  }

  const session = getSession.data;
  const progress = {
    completed: session.completion_status === 'completed',
    passed: session.success_status === 'passed',
    score: session.score?.scaled ? session.score.scaled * 100 : 0,
    timeSpent: session.time_spent_seconds
      ? Math.round(session.time_spent_seconds / 60)
      : 0,
  };

  return (
    <div className="progress-tracker">
      <h3>Course Progress</h3>
      <div className="progress-bar">
        <div
          className="progress-fill"
          style={{ width: `${progress.score}%` }}
        />
      </div>
      <p>Status: {session.completion_status}</p>
      <p>Score: {progress.score.toFixed(1)}%</p>
      <p>Time Spent: {progress.timeSpent} minutes</p>
      {progress.completed && (
        <div className="completion-badge">Course Completed!</div>
      )}
    </div>
  );
}

State Management

Using Context API

// context/ScormContext.tsx
import { createContext, useContext, ReactNode } from 'react';
import { useScormAPI } from '../hooks/useScormAPI';

interface ScormContextType {
  uploadPackage: any;
  packages: any;
  launchSession: any;
  getSession: any;
}

const ScormContext = createContext<ScormContextType | undefined>(undefined);

export function ScormProvider({ children }: { children: ReactNode }) {
  const scormAPI = useScormAPI();

  return (
    <ScormContext.Provider value={scormAPI}>
      {children}
    </ScormContext.Provider>
  );
}

export function useScorm() {
  const context = useContext(ScormContext);
  if (!context) {
    throw new Error('useScorm must be used within ScormProvider');
  }
  return context;
}

App Setup

// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ScormProvider } from './context/ScormContext';
import { PackageList } from './components/PackageList';
import { ScormUpload } from './components/ScormUpload';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ScormProvider>
        <div className="app">
          <h1>SCORM Learning Platform</h1>
          <ScormUpload userId="user-123" />
          <PackageList />
        </div>
      </ScormProvider>
    </QueryClientProvider>
  );
}

export default App;

Complete Example

Full Integration

// pages/CoursePage.tsx
import { useState } from 'react';
import { useScorm } from '../context/ScormContext';
import { ScormPlayer } from '../components/ScormPlayer';
import { ProgressTracker } from '../components/ProgressTracker';

export function CoursePage({ courseId }: { courseId: string }) {
  const { launchSession, getSession } = useScorm();
  const [sessionId, setSessionId] = useState<string | null>(null);

  async function handleLaunch() {
    const launch = await launchSession.mutateAsync({
      packageId: courseId,
      userId: 'user-123',
    });
    setSessionId(launch.session_id);
  }

  function handleComplete(result: any) {
    console.log('Course completed!', result);
    // Update user's course completion status
    // Show certificate
    // Update grade book
  }

  return (
    <div className="course-page">
      {!sessionId ? (
        <button onClick={handleLaunch}>Start Course</button>
      ) : (
        <>
          <ScormPlayer
            packageId={courseId}
            userId="user-123"
            onComplete={handleComplete}
          />
          <ProgressTracker sessionId={sessionId} />
        </>
      )}
    </div>
  );
}

Best Practices

  1. Error Boundaries: Wrap SCORM components in error boundaries
  2. Loading States: Always show loading indicators
  3. Error Handling: Display user-friendly error messages
  4. Optimistic Updates: Update UI optimistically when possible
  5. Caching: Use React Query for automatic caching
  6. Polling: Poll session status for real-time updates

Related Documentation


Last Updated: 2025-01-15