← Academy

How to Build a Fitness App in Next.js

From an empty Next.js project to a deployed, video-rich fitness app , using the Exercise API.

This guide builds the foundation of a fitness app twice over: first a server-rendered exercise library (browse 700+ movements with video and filters), then a workout generator that turns a few inputs into a structured plan. Both run on the ymove Exercise API and the official TypeScript SDK.

Every step is real, runnable code from the open-source examples, and each chapter opens with a live demo of what you are about to build.

2 chapters · ~55 min · Next.js · TypeScript · Tailwind · Vercel

Chapter 1 · Project: Exercise Browser

Build a Next.js exercise library

Your first calls to the API: list and filter exercises, server-rendered, with portrait video.

~25 min · BeginnerView on GitHub →

What you are building

Live demoexercises.list (sample data)

Movement library

10 movements

  • Barbell Back Squat

    quads · barbell

    14s
  • Dumbbell Bench Press

    chest · dumbbell

    12s
  • Pull-up

    back · bodyweight

    11s
  • Romanian Deadlift

    hamstrings · barbell

    15s
  • Overhead Press

    shoulders · barbell

    13s
  • Cable Fly

    chest · cable

    12s
  • Goblet Squat

    quads · dumbbell

    13s
  • Lat Pulldown

    back · cable

    12s
  • Hip Thrust

    glutes · barbell

    14s
  • Push-up

    chest · bodyweight

    10s

Tap a chip , in the real app each chip is a URL param and the list re-renders from the server.

A filterable movement library. Tap a muscle group and the list narrows; each row shows a portrait thumbnail, the title, the muscle and equipment, and the clip length. In the real app every filter is a URL parameter, so the page is server-rendered and every filtered view is a shareable link.

Install the SDK and create a client

Start from any Next.js app (Pages Router here). The only dependency you add is the official SDK.

Create a single client in lib/ymove.ts. Set YMOVE_API_KEY in your environment; it is read on the server only, so it never ships to the browser.

npm install ymove-exercise-api
lib/ymove.ts
import { YMoveClient } from 'ymove-exercise-api';

// Read on the server only, so the key never reaches the browser. Get a key in the docs at https://ymove.app/exercise-api
const apiKey = process.env.YMOVE_API_KEY;
if (!apiKey) throw new Error('Missing YMOVE_API_KEY , get a key at https://ymove.app/exercise-api');

export const ymove = new YMoveClient(apiKey);

Fetch exercises on the server

Read the three filters off the query string in getServerSideProps and pass them straight to ymove.exercises.list. Running on the server keeps the key private and ships fully-rendered HTML, good for SEO and first paint.

hasVideo: true limits results to exercises that have a playable clip. result.pagination.total is the live count for the header. The SDK types every parameter, so muscleGroup, equipment and difficulty are autocompleted unions.

pages/index.tsx
import type { GetServerSideProps } from 'next';
import type { Exercise, MuscleGroupSlug, EquipmentSlug, Difficulty } from 'ymove-exercise-api';
import { ymove } from '../lib/ymove';

interface Filters { muscleGroup?: MuscleGroupSlug; equipment?: EquipmentSlug; difficulty?: Difficulty }
interface Props { initialExercises: Exercise[]; total: number; filters: Filters }

export const getServerSideProps: GetServerSideProps<Props> = async ({ query }) => {
  const filters: Filters = {
    muscleGroup: typeof query.muscleGroup === 'string' ? (query.muscleGroup as MuscleGroupSlug) : undefined,
    equipment:   typeof query.equipment === 'string'   ? (query.equipment as EquipmentSlug)     : undefined,
    difficulty:  typeof query.difficulty === 'string'  ? (query.difficulty as Difficulty)       : undefined,
  };

  const result = await ymove.exercises.list({ ...filters, hasVideo: true, pageSize: 30 });

  return {
    props: {
      initialExercises: result.data,
      total: result.pagination.total,
      filters: Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== undefined)),
    },
  };
};

Render the list (mind the portrait video)

Map result.data to rows. Each Exercise carries title, muscleGroup, equipment, hasVideo, videoDurationSecs, thumbnailUrl and videoUrl.

Important: ymove clips are portrait (vertical, ~9:16), shot for mobile. Square thumbnails crop fine, but if you embed the video use a portrait container with object-fit: cover so it fills the frame instead of letterboxing. Video URLs are signed and expire after 48h , fetch fresh, never cache them.

pages/index.tsx
<p className="tabular-nums text-ink-400">{total.toLocaleString()} Movements</p>

<ul className="divide-y divide-ink-800">
  {initialExercises.map((ex) => (
    <li key={ex.id} className="py-4 flex gap-4 items-center">
      <img src={ex.thumbnailUrl} alt="" className="h-14 w-14 rounded object-cover" loading="lazy" />
      <div className="min-w-0 flex-1">
        <h2 className="font-medium truncate">{ex.title}</h2>
        <p className="text-sm text-ink-400 truncate">{ex.muscleGroup}{ex.equipment ? ` · ${ex.equipment}` : ''}</p>
      </div>
      {ex.hasVideo && <span className="text-xs text-lime-500 tabular-nums">{ex.videoDurationSecs}s</span>}
    </li>
  ))}
</ul>
embedding the portrait video
// portrait container so a 9:16 clip fills the frame
<div className="aspect-[9/16] w-full overflow-hidden rounded-xl bg-ink-800">
  <video src={ex.videoUrl} className="h-full w-full object-cover" controls playsInline />
</div>

URL-driven filters (no client state)

A floating "Filter" pill opens a bottom sheet of chips. Each chip is a Link to the same page with one query param set. getServerSideProps re-reads the query and re-fetches , so filtering needs no client state, no loading spinner, and the back button just works.

Clicking the already-active chip omits the param, which clears that filter.

pages/index.tsx
function FilterSection({ label, options, current, param }: {
  label: string; options: string[]; current?: string; param: string;
}) {
  return (
    <section className="mb-6">
      <h4 className="text-xs uppercase tracking-widest text-ink-400 mb-3">{label}</h4>
      <div className="flex flex-wrap gap-2">
        {options.map((opt) => {
          const active = current === opt;
          const next = new URLSearchParams();
          if (!active) next.set(param, opt);   // clicking the active chip clears it
          return (
            <Link key={opt} href={`/?${next.toString()}`}
              className={active ? 'bg-ink-200 text-ink-950 rounded-full px-3 py-1.5 text-sm'
                               : 'bg-ink-800 text-ink-200 rounded-full px-3 py-1.5 text-sm'}>
              {opt}
            </Link>
          );
        })}
      </div>
    </section>
  );
}

Deploy

Set YMOVE_API_KEY in your host and deploy. On Vercel that is one env var. You now have a typed, server-rendered exercise library in under 200 lines , the base the next chapter builds on.

Get your key in the docs at https://ymove.app/exercise-api
.env.local
YMOVE_API_KEY=ym_your_key_here
npm run dev   # http://localhost:3000

Chapter 2 · Project: Workout Generator

Build a workout plan generator

Turn a few inputs into a structured plan with sets, reps and rest , an AI-style generator.

~30 min · BeginnerView on GitHub →

What you are building

Live demoworkouts.generate (sample data)

Chest · Intermediate

4 exercises · ~41 min · intermediate

  • Barbell Bench Press

    SetRepsRest
    16-890s
    26-890s
    36-890s
    46-890s
  • Incline Dumbbell Press

    SetRepsRest
    18-1275s
    28-1275s
    38-1275s
  • Cable Fly

    SetRepsRest
    112-1560s
    212-1560s
    312-1560s
  • Push-up (burnout)

    SetRepsRest
    1AMRAP60s
    2AMRAP60s

Tabular numerals keep the prescription scannable, the Hevy convention.

A one-screen generator. Pick a muscle group and difficulty, and workouts.generate returns a named plan with an estimated duration and a set/rep/rest table per exercise. Regenerate for a fresh plan. The same call is the engine behind an AI workout generator or a printable routine.

A GET form drives everything

Radio chips for muscle group, equipment, difficulty and exercise count. Submitting navigates to /?muscleGroup=...&difficulty=..., so each plan is a shareable, server-rendered URL with zero client JavaScript for the form (a peer-checked Tailwind trick styles the selected chip).

npm install ymove-exercise-api
pages/index.tsx
<form method="GET" action="/" className="space-y-5">
  <Selector label="Muscle group"  name="muscleGroup"  options={MUSCLE_GROUPS}    current={params.muscleGroup} />
  <Selector label="Equipment"     name="equipment"    options={EQUIPMENT_OPTIONS} current={params.equipment ?? 'any'} />
  <Selector label="Difficulty"    name="difficulty"   options={DIFFICULTIES}     current={params.difficulty} />
  <Selector label="Exercises"     name="exerciseCount" options={COUNTS.map(String)} current={String(params.exerciseCount)} />
  <button type="submit" className="w-full rounded-lg bg-lime-500 text-ink-950 font-semibold py-3">Generate workout</button>
</form>

Generate the plan

Read the params in getServerSideProps and call ymove.workouts.generate. It returns a typed Workout: name, estimatedMinutes, difficulty, and exercises[] where each item has the Exercise plus sets, reps and restSeconds.

Wrap it in try/catch so a bad input shows a message instead of a 500.

pages/index.tsx
export const getServerSideProps: GetServerSideProps<Props> = async ({ query }) => {
  const params = {
    muscleGroup: (typeof query.muscleGroup === 'string' ? query.muscleGroup : 'chest') as MuscleGroupSlug,
    equipment: typeof query.equipment === 'string' && query.equipment !== 'any'
      ? (query.equipment as EquipmentSlug) : undefined,
    difficulty: (typeof query.difficulty === 'string' ? query.difficulty : 'intermediate') as Difficulty,
    exerciseCount: typeof query.exerciseCount === 'string' ? parseInt(query.exerciseCount, 10) : 6,
  };
  try {
    const workout = await ymove.workouts.generate(params);
    return { props: { workout, params, error: null } };
  } catch (e: any) {
    return { props: { workout: null, params, error: e?.message ?? 'Failed to generate' } };
  }
};

Render the set table

Each exercise is a card with a compact SET / REPS / REST table in tabular-nums , the Hevy convention that makes a plan scannable at a glance. A "Regenerate" link re-requests the same params with a cache-buster.

pages/index.tsx
{workout.exercises.map((we, i) => (
  <li key={we.exercise.id ?? i} className="p-5">
    <h3 className="font-medium">{we.exercise.title}</h3>
    <table className="mt-3 w-full text-sm tabular-nums">
      <thead><tr className="text-xs uppercase text-ink-400">
        <th className="text-left">Set</th><th className="text-left">Reps</th><th className="text-right">Rest</th>
      </tr></thead>
      <tbody>
        {Array.from({ length: we.sets }).map((_, s) => (
          <tr key={s}><td>{s + 1}</td><td>{we.reps}</td><td className="text-right">{we.restSeconds}s</td></tr>
        ))}
      </tbody>
    </table>
  </li>
))}

Common questions

Do I need to build my own exercise database?
No , that is the whole point of the API. You get 700+ exercises with HD video, instructions, and muscle data behind one call, so you build the app, not the content library.
Are the clips video or GIFs?
Real HD video (portrait, ~9:16), plus thumbnails , not GIFs. Size your player to a portrait container with object-fit: cover, and remember video URLs are signed and expire after 48 hours, so fetch fresh rather than caching them.
What does it cost to build a fitness app on the API?
You can start on a free trial, then move to a paid tier as your usage grows , see ymove.app/exercise-api for current pricing. The cost you are avoiding is producing and hosting hundreds of exercise videos yourself.
Do I need a backend?
No. getServerSideProps runs on the server and keeps your API key private, so a single Next.js app is enough to ship. Add a backend later only when you need accounts or your own data.