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_KEYin client code. In a React/Next.js app, anyprocess.env.VARreference in a client component is only substituted at build time when the name starts withNEXT_PUBLIC_. BundlingCONNECT_API_KEYwould either leak the secret to every visitor (Next.jsNEXT_PUBLIC_*convention) or silently resolve toundefined(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
- Error Boundaries: Wrap SCORM components in error boundaries
- Loading States: Always show loading indicators
- Error Handling: Display user-friendly error messages
- Optimistic Updates: Update UI optimistically when possible
- Caching: Use React Query for automatic caching
- Polling: Poll session status for real-time updates
Related Documentation
Last Updated: 2025-01-15