Tutorial April 29, 2026 · 10 min read

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

How to Use an Exercise API in React Cover Image - WorkoutX Blog
bolt Quick Summary (TL;DR)

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.

src/hooks/useExerciseAPI.js
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:

src/components/ExerciseBrowser.jsx
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:

src/components/ExerciseCard.jsx
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

src/App.js
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.poster first 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 media URLs in localStorage: 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.

code

Free API key — start building in 30 seconds

500 free requests · 1,900+ exercises · 720p video animations · No credit card

Get Free API Key arrow_forward