How to Use an Exercise API in React:
Complete Tutorial with Animations & Filtering
Build a full-featured exercise browser in React — fetch exercises, play looping exercise videos, filter by body part and muscle, add search, and paginate results. Uses the free WorkoutX API with real working code.
verified Published by the WorkoutX team — builders of the exercise API used by fitness app developers. Learn more
A complete hands-on tutorial for building an exercise browser in React 18+. Walk through building a custom API fetch hook, rendering looping MP4 exercise videos, adding pagination controls, and filtering by muscle group.
Prerequisites
- React 18+ (Create React App or Vite)
- Basic knowledge of React hooks (
useState,useEffect) - A free WorkoutX API key — takes 30 seconds
Free tier is enough for this tutorial. The WorkoutX free plan gives you 500 requests in total (up to 10 results per request, with watermarked video previews) — more than enough to build and test this entire exercise browser.
Step 1 — Project Setup
Create a new React project and set up your API key as an environment variable:
npx create-react-app exercise-browser
cd exercise-browser
Create a .env file in the project root:
REACT_APP_WORKOUTX_KEY=wx_your_api_key_here
Security note: Never commit your API key to Git. Add .env to your .gitignore. For production apps, proxy API calls through your own backend to keep the key server-side.
Step 2 — Create a Custom API Hook
Start by building a reusable useExerciseAPI hook that handles fetching, loading, and error state. This keeps your components clean.
import { useState, useCallback } from 'react';
const BASE_URL = 'https://api.workoutxapp.com/v2';
const API_KEY = process.env.REACT_APP_WORKOUTX_KEY;
export function useExerciseAPI() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const request = useCallback(async (endpoint, params = {}) => {
setLoading(true);
setError(null);
const url = new URL(`${BASE_URL}${endpoint}`);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '') url.searchParams.set(k, v);
});
try {
const res = await fetch(url.toString(), {
headers: { 'X-WorkoutX-Key': API_KEY },
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `HTTP ${res.status}`);
}
return await res.json();
} catch (err) {
setError(err.message);
return null;
} finally {
setLoading(false);
}
}, []);
return { request, loading, error };
}
Step 3 — Exercise List with Body Part Filter
Build the main exercise browser component with filtering by body part:
import { useState, useEffect } from 'react';
import { useExerciseAPI } from '../hooks/useExerciseAPI';
import ExerciseCard from './ExerciseCard';
const BODY_PARTS = [
'All', 'Back', 'Cardio', 'Chest', 'Lower Arms',
'Lower Legs', 'Neck', 'Shoulders', 'Upper Arms',
'Upper Legs', 'Waist'
];
export default function ExerciseBrowser() {
const { request, loading, error } = useExerciseAPI();
const [exercises, setExercises] = useState([]);
const [total, setTotal] = useState(0);
const [bodyPart, setBodyPart] = useState('All');
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const LIMIT = 10; // the Free plan returns at most 10 per request
useEffect(() => {
async function fetchExercises() {
const params = { limit: LIMIT, offset: page * LIMIT };
if (search.trim()) {
// Use name search when user types
params.name = search.trim();
} else if (bodyPart !== 'All') {
params.bodyPart = bodyPart;
}
const data = await request('/exercises', params);
if (data) {
setExercises(data.results || []);
setTotal(data.total || 0);
}
}
fetchExercises();
}, [bodyPart, search, page, request]);
// Reset to page 0 when filter changes
const handleBodyPartChange = (bp) => {
setBodyPart(bp);
setSearch('');
setPage(0);
};
const handleSearch = (e) => {
setSearch(e.target.value);
setPage(0);
};
return (
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '24px 16px' }}>
<h1>Exercise Browser</h1>
<p style={{ color: '#888' }}>{total} exercises</p>
{/* Search */}
<input
type="text"
placeholder="Search exercises..."
value={search}
onChange={handleSearch}
style={{
width: '100%', padding: '10px 14px', borderRadius: 8,
border: '1px solid #333', background: '#1a1a1a',
color: '#fff', fontSize: 15, marginBottom: 16
}}
/>
{/* Body Part Filter */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 24 }}>
{BODY_PARTS.map(bp => (
<button
key={bp}
onClick={() => handleBodyPartChange(bp)}
style={{
padding: '6px 16px', borderRadius: 99, fontSize: 13,
border: bodyPart === bp ? 'none' : '1px solid #333',
background: bodyPart === bp
? 'linear-gradient(135deg, #f5f5f7, rgba(255,255,255,0.4))'
: 'transparent',
color: bodyPart === bp ? '#0a0a0a' : '#aaa',
fontWeight: bodyPart === bp ? 700 : 400,
cursor: 'pointer',
}}
>
{bp}
</button>
))}
</div>
{/* Error */}
{error && (
<div style={{ color: '#f5f5f7', padding: 16, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.4)', borderRadius: 8 }}>
Error: {error}
</div>
)}
{/* Loading */}
{loading && (
<div style={{ textAlign: 'center', color: '#888', padding: 40 }}>
Loading exercises...
</div>
)}
{/* Grid */}
{!loading && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 20
}}>
{exercises.map(ex => (
<ExerciseCard key={ex.id} exercise={ex} />
))}
</div>
)}
{/* Pagination */}
{total > LIMIT && (
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 32 }}>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
style={{
padding: '8px 20px', borderRadius: 8, border: '1px solid #333',
background: 'transparent', color: page === 0 ? '#555' : '#fff',
cursor: page === 0 ? 'not-allowed' : 'pointer'
}}
>
← Previous
</button>
<span style={{ color: '#888', lineHeight: '36px', fontSize: 13 }}>
Page {page + 1} of {Math.ceil(total / LIMIT)}
</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={(page + 1) * LIMIT >= total}
style={{
padding: '8px 20px', borderRadius: 8, border: '1px solid #333',
background: 'transparent',
color: (page + 1) * LIMIT >= total ? '#555' : '#fff',
cursor: (page + 1) * LIMIT >= total ? 'not-allowed' : 'pointer'
}}
>
Next →
</button>
</div>
)}
</div>
);
}
Step 4 — Exercise Card with Video
Build the ExerciseCard component that plays the exercise animation and shows its details. Each exercise has a media object with an mp4 (600×720, 30 fps) and a poster image; play it muted and looping. The links expire after 24–25 hours, so keep them in component state only and store exercise ids, not URLs:
import { useState } from 'react';
export default function ExerciseCard({ exercise }) {
const [mediaError, setMediaError] = useState(false);
const [showInstructions, setShowInstructions] = useState(false);
return (
<div style={{
background: '#1a1a1a', borderRadius: 12,
border: '1px solid #2a2a2a', overflow: 'hidden',
transition: 'transform 0.2s, border-color 0.2s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = '#f5f5f7'}
onMouseLeave={e => e.currentTarget.style.borderColor = '#2a2a2a'}
>
{/* Animation */}
<div style={{ position: 'relative', background: '#111', height: 260 }}>
{exercise.media && !mediaError ? (
<video
src={exercise.media.mp4}
poster={exercise.media.poster}
autoPlay loop muted playsInline
aria-label={`${exercise.name} demonstration`}
onError={() => setMediaError(true)}
style={{
width: '100%', height: '100%',
objectFit: 'cover', display: 'block'
}}
/>
) : (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100%', color: '#555', fontSize: 13
}}>
Animation unavailable
</div>
)}
{/* Difficulty badge */}
<span style={{
position: 'absolute', top: 10, right: 10,
padding: '3px 10px', borderRadius: 99, fontSize: 11,
fontWeight: 700, textTransform: 'uppercase',
background: exercise.difficulty === 'beginner'
? 'rgba(255,255,255,0.2)'
: exercise.difficulty === 'intermediate'
? 'rgba(255,255,255,0.2)'
: '#f5f5f7',
color: exercise.difficulty === 'beginner'
? '#f5f5f7'
: exercise.difficulty === 'intermediate'
? '#f5f5f7'
: '#0a0a0a',
}}>
{exercise.difficulty}
</span>
</div>
{/* Info */}
<div style={{ padding: '14px 16px' }}>
<h3 style={{ margin: '0 0 6px', fontSize: 15, fontWeight: 700, color: '#f5f5f7' }}>
{exercise.name}
</h3>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<Tag color="#f5f5f7">{exercise.bodyPart}</Tag>
<Tag color="#f5f5f7">{exercise.target}</Tag>
<Tag color="#515154">{exercise.equipment}</Tag>
</div>
{/* Calorie info */}
{exercise.caloriesPerMinute && (
<p style={{ margin: '0 0 10px', fontSize: 12, color: '#888' }}>
🔥 ~{exercise.caloriesPerMinute} kcal/min
· {exercise.mechanic} · {exercise.force}
</p>
)}
{/* Instructions toggle */}
<button
onClick={() => setShowInstructions(s => !s)}
style={{
background: 'none', border: '1px solid #333', borderRadius: 6,
color: '#f5f5f7', fontSize: 12, padding: '5px 12px', cursor: 'pointer',
width: '100%'
}}
>
{showInstructions ? 'Hide' : 'Show'} instructions
</button>
{showInstructions && (
<ol style={{ margin: '12px 0 0', paddingLeft: 18 }}>
{exercise.instructions.map((step, i) => (
<li key={i} style={{ color: '#aaa', fontSize: 12, lineHeight: 1.6, marginBottom: 4 }}>
{step}
</li>
))}
</ol>
)}
</div>
</div>
);
}
function Tag({ children, color }) {
return (
<span style={{
padding: '2px 8px', borderRadius: 99, fontSize: 11, fontWeight: 600,
background: `${color}18`, color, border: `1px solid ${color}33`
}}>
{children}
</span>
);
}
Step 5 — Wire It Up
import ExerciseBrowser from './components/ExerciseBrowser';
function App() {
return (
<div style={{ minHeight: '100vh', background: '#111', color: '#fff' }}>
<ExerciseBrowser />
</div>
);
}
export default App;
npm start
Your exercise browser is now running at http://localhost:3000 with filtering, search, video animations, and pagination.
Step 6 — Advanced: Multi-Filter Search
In v2, every filter is a query parameter on /exercises, so you can combine several in one request on any plan — great for "show me beginner chest exercises using dumbbells" (like a dumbbell bench press):
// Combine filters in one request
const data = await request('/exercises', {
bodyPart: 'Chest',
equipment: 'Dumbbell',
difficulty: 'beginner',
limit: 10,
offset: 0,
});
const exercises = data.results;
Other filters you can add: target, category, mechanic, force, muscle and model (male or female).
Step 7 — Calorie Calculator
Use the calories endpoint (available on every plan) to show users how many calories they'll burn doing an exercise for their body weight and session length. High-intensity moves like burpees and mountain climbers burn the most per minute — a great data point to surface in your UI:
// Get a calorie estimate: weight in kg, duration in minutes
const calories = await request(`/exercises/${exercise.id}/calories`, {
weightKg: 75,
minutes: 15,
});
// Returns: { id, name, weightKg, minutes, met, baseKcalPerMin, kcalPerMin, totalKcal, note }
console.log(`Burns ~${calories.totalKcal} kcal in 15 minutes`);
Each exercise in a list response also carries caloriesPerMinute and met, so you can show a per-minute figure on every card without an extra request.
Performance Tips
- Debounce search input — wrap the search handler with a 300ms debounce to avoid a request on every keystroke
- Posters in long lists — render
media.posterfirst and start the video when the card is visible or hovered - Cache data, not media links — React Query's cache avoids redundant API calls, but don't persist
mediaURLs inlocalStorage: they expire after 24–25 hours. Store exercise ids and fetch fresh links from/exercises/{id}or/exercises/{id}/media - Server-side key — in production, proxy API calls through your backend so the API key is never exposed in the browser
- Pagination over "load all" — never try to load the whole catalog in a browser app; fetch 10–20 exercises per page instead (the Free plan returns at most 10)
What you built: A full exercise browser with looping video animations, body part filtering, name search, and pagination — using less than 150 lines of React code and the free WorkoutX API tier.
Free API key — start building in 30 seconds
500 free requests · 1,900+ exercises · 720p video animations · No credit card