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.
What you are building
Movement library
10 movements
- 14s
Barbell Back Squat
quads · barbell
- 12s
Dumbbell Bench Press
chest · dumbbell
- 11s
Pull-up
back · bodyweight
- 15s
Romanian Deadlift
hamstrings · barbell
- 13s
Overhead Press
shoulders · barbell
- 12s
Cable Fly
chest · cable
- 13s
Goblet Squat
quads · dumbbell
- 12s
Lat Pulldown
back · cable
- 14s
Hip Thrust
glutes · barbell
- 10s
Push-up
chest · bodyweight
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.
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.
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.
<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>// 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.
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.
YMOVE_API_KEY=ym_your_key_here
npm run dev # http://localhost:3000Chapter 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.
What you are building
Chest · Intermediate
4 exercises · ~41 min · intermediate
Barbell Bench Press
Set Reps Rest 1 6-8 90s 2 6-8 90s 3 6-8 90s 4 6-8 90s Incline Dumbbell Press
Set Reps Rest 1 8-12 75s 2 8-12 75s 3 8-12 75s Cable Fly
Set Reps Rest 1 12-15 60s 2 12-15 60s 3 12-15 60s Push-up (burnout)
Set Reps Rest 1 AMRAP 60s 2 AMRAP 60s
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).
<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.
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.
{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.