A self-hosted fitness tracker keeps your workout history under your control. You own the data, you decide how it gets tracked, and nobody charges you monthly to look at your own progress.
This tutorial walks through building a two-tier fitness platform. The public side is a searchable exercise library with form guides and demonstration images, the kind of page that ranks when someone searches "barbell squat form guide."
The private side is an authenticated workout tracker where users log sets, reps, and weight, then watch their volume climb on Recharts line charts. Strapi 5 handles the content model, the exercise media, and a custom service that crunches workout stats. Next.js 16 uses the App Router to render the public library with server-side rendering (SSR) for search visibility and the tracker with Server Actions for logging.
In brief:
- Model four related Collection Types in Strapi 5 (Exercise, Workout Session, Workout Set, and Personal Record) with nested relations that chain users to their training history.
- Build a custom Strapi service that queries the Document Service API to calculate total volume, personal records, and weekly streaks.
- Render an SEO-ready exercise library with Next.js 16 Server Components and log workouts through Server Actions.
- Visualize progress with Recharts line charts on an authenticated dashboard fed by your custom stats endpoint.
Prerequisites
Pin these versions. The JavaScript ecosystem moves fast, and mismatched majors are where most copy-paste tutorials break.
- Node.js v22 or v24 (as of June 2026, v24 is Active LTS, v22 is Maintenance LTS, and v20 is EOL)
- Strapi 5.48.0+ (verify the exact latest stable with
npx create-strapi@latest) - Next.js 16.2.x (App Router, Server Components, Server Actions)
- React 19.2.x (ships with Next.js 16)
- Recharts 2.13.0-alpha.2+ for early React 19 compatibility, with a React 19
react-isoverride required in Recharts 2.x - date-fns latest stable for date formatting
- PostgreSQL 16.x for production
- Basic familiarity with TypeScript, React, and REST APIs
- A code editor and a terminal
Setting Up the Strapi Backend
Strapi gives you the content modeling and APIs without writing controllers for every CRUD (Create, Read, Update, Delete) operation. You'll add custom logic only where the stats aggregation lives.
Step 1: Install Strapi 5
Run the installer. The CLI walks you through database selection and connection details interactively.
npx create-strapi@latest fitness-backendThe CLI prompts for your database connection details. Select PostgreSQL and point it at a local instance. If you'd rather start on SQLite for local development, select SQLite when prompted and the installer configures it automatically.
For a production PostgreSQL setup, your config/database.ts reads connection values from environment variables:
// config/database.ts
import path from 'path';
export default ({ env }) => {
const client = env('DATABASE_CLIENT', 'sqlite');
const connections = {
postgres: {
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
schema: env('DATABASE_SCHEMA', 'public'),
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
sqlite: {
connection: {
filename: path.join(__dirname, '..', env('DATABASE_FILENAME', 'data.db')),
},
useNullAsDefault: true,
},
};
return {
connection: {
client,
...connections[client],
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
},
};
};One gotcha worth knowing: a fresh PostgreSQL user often lacks SCHEMA permissions, which surfaces as a 500 error when the Admin Panel loads. Grant those permissions explicitly before starting Strapi.
Start the server with npm run develop and create your admin account at http://localhost:1337/admin.
Step 2: Define the Exercise and Workout Session Content-Types
You can build these through the Content-Type Builder in the Admin Panel, but defining the schema.json files directly keeps everything in version control and makes the relations explicit.
The Exercise Collection Type is the heart of the public library. It carries a name, a Rich Text (Blocks) description for form guides, enum fields for muscle group and equipment, a difficulty level, and demonstration images.
// src/api/exercise/content-types/exercise/schema.json
{
"kind": "collectionType",
"collectionName": "exercises",
"info": {
"singularName": "exercise",
"pluralName": "exercises",
"displayName": "Exercise"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"name": {
"type": "string",
"required": true,
"maxLength": 99
},
"slug": {
"type": "uid",
"targetField": "name",
"required": true
},
"description": {
"type": "blocks"
},
"instructions": {
"type": "text"
},
"muscleGroup": {
"type": "enumeration",
"enum": ["chest", "back", "legs", "shoulders", "arms", "core"],
"required": true
},
"equipment": {
"type": "enumeration",
"enum": ["barbell", "dumbbell", "machine", "bodyweight", "cable"],
"required": true
},
"difficulty": {
"type": "enumeration",
"enum": ["beginner", "intermediate", "advanced"],
"default": "beginner"
},
"demonstrationImages": {
"type": "media",
"multiple": true
}
}
}The description field uses the Rich Text (Blocks) editor, which stores content as an array of structured block objects rather than markdown. That structure maps cleanly to frontend components when you render form cues. If you create this field through the Content-Type Builder UI, it appears as "Rich Text (Blocks)" in the field picker.
The Workout Session Collection Type records a single training session tied to a user.
// src/api/workout-session/content-types/workout-session/schema.json
{
"kind": "collectionType",
"collectionName": "workout_sessions",
"info": {
"singularName": "workout-session",
"pluralName": "workout-sessions",
"displayName": "Workout Session"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"name": {
"type": "string",
"required": true,
"maxLength": 99
},
"date": {
"type": "date",
"required": true
},
"duration": {
"type": "integer"
},
"notes": {
"type": "text"
},
"status": {
"type": "enumeration",
"enum": ["pending", "in_progress", "completed"],
"default": "pending"
},
"user": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user",
"inversedBy": "workoutSessions"
}
}
}The user relation is the owning side of a many-to-one. Add the inverse side to the users-permissions user model so each user knows about their sessions. You configure this by editing the inverse side schema (e.g., ./src/extensions/users-permissions/content-types/user/schema.json) and adding a workoutSessions relation field with "relation": "oneToMany", "target": "api::workout-session.workout-session", and "mappedBy": "user".
Step 3: Model Sets and Personal Records
A workout is more than a session record. Each session contains individual sets, and each set targets a specific exercise. The Workout Set Collection Type captures that granularity.
// src/api/workout-set/content-types/workout-set/schema.json
{
"kind": "collectionType",
"collectionName": "workout_sets",
"info": {
"singularName": "workout-set",
"pluralName": "workout-sets",
"displayName": "Workout Set"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"setNumber": {
"type": "integer",
"required": true
},
"reps": {
"type": "integer",
"required": true
},
"weight": {
"type": "decimal",
"required": true
},
"restTime": {
"type": "integer"
},
"exercise": {
"type": "relation",
"relation": "manyToOne",
"target": "api::exercise.exercise"
},
"workoutSession": {
"type": "relation",
"relation": "manyToOne",
"target": "api::workout-session.workout-session",
"inversedBy": "workoutSets"
}
}
}Add the inverse workoutSets field to the Workout Session schema so you can populate a session's sets in one query:
// src/api/workout-session/content-types/workout-session/schema.json (add to attributes)
"workoutSets": {
"type": "relation",
"relation": "oneToMany",
"target": "api::workout-set.workout-set",
"mappedBy": "workoutSession"
}The Personal Record Collection Type stores a user's best lift for a given exercise. The stats service recalculates these during scheduled syncs or queries, not immediately as workouts come in.
// src/api/personal-record/content-types/personal-record/schema.json
{
"kind": "collectionType",
"collectionName": "personal_records",
"info": {
"singularName": "personal-record",
"pluralName": "personal-records",
"displayName": "Personal Record"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"maxWeight": {
"type": "decimal",
"required": true
},
"maxReps": {
"type": "integer",
"required": true
},
"dateAchieved": {
"type": "date",
"required": true
},
"exercise": {
"type": "relation",
"relation": "manyToOne",
"target": "api::exercise.exercise"
},
"user": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}
}
}Restart Strapi after editing schema files. The relations now chain together: users own sessions, sessions own sets, and sets point back at the public exercise catalog.
Step 4: Build the Stats Aggregation Custom Service
This is where the platform earns its keep. A custom service reads a user's workout history through the Document Service API and computes total volume, personal records, and weekly streaks. The Document Service API is the Strapi 5 replacement for the old Entity Service, and content CRUD operations in core services are intended to use it.
Create a dedicated stats service file separate from the auto-generated core service.
// src/api/workout-session/services/stats.ts
import { factories } from '@strapi/strapi';
import { startOfWeek, format, differenceInCalendarWeeks, parseISO } from 'date-fns';
interface VolumePoint {
date: string;
exercise: string;
volume: number;
}
interface PersonalRecord {
exercise: string;
maxWeight: number;
reps: number;
dateAchieved: string;
}
export default ({ strapi }) => ({
async getStats(userDocumentId: string) {
const sessions = await strapi
.documents('api::workout-session.workout-session')
.findMany({
filters: {
user: { documentId: { $eq: userDocumentId } },
status: { $eq: 'completed' },
},
populate: {
workoutSets: {
populate: {
exercise: {
fields: ['name', 'slug'],
},
},
},
},
sort: ['date:asc'],
status: 'published',
});
const volumeHistory: VolumePoint[] = [];
const recordMap = new Map<string, PersonalRecord>();
const weekSet = new Set<string>();
for (const session of sessions) {
const sessionDate = session.date as string;
const weekKey = format(startOfWeek(parseISO(sessionDate)), 'yyyy-MM-dd');
weekSet.add(weekKey);
const sets = (session.workoutSets ?? []) as any[];
const volumeByExercise = new Map<string, number>();
for (const set of sets) {
const exerciseName = set.exercise?.name ?? 'Unknown';
const volume = (set.reps ?? 0) * (set.weight ?? 0);
volumeByExercise.set(
exerciseName,
(volumeByExercise.get(exerciseName) ?? 0) + volume
);
const existing = recordMap.get(exerciseName);
if (!existing || set.weight > existing.maxWeight) {
recordMap.set(exerciseName, {
exercise: exerciseName,
maxWeight: set.weight,
reps: set.reps,
dateAchieved: sessionDate,
});
}
}
for (const [exercise, volume] of volumeByExercise) {
volumeHistory.push({ date: sessionDate, exercise, volume });
}
}
const streak = this.calculateStreak(Array.from(weekSet));
return {
volumeHistory,
personalRecords: Array.from(recordMap.values()),
weeklyWorkouts: weekSet.size,
currentStreak: streak,
};
},
calculateStreak(weekKeys: string[]): number {
if (weekKeys.length === 0) return 0;
const sorted = weekKeys
.map((k) => parseISO(k))
.sort((a, b) => b.getTime() - a.getTime());
let streak = 1;
for (let i = 0; i < sorted.length - 1; i++) {
const gap = differenceInCalendarWeeks(sorted[i], sorted[i + 1]);
if (gap === 1) {
streak++;
} else {
break;
}
}
return streak;
},
});A few details worth calling out. The explicit populate walks two levels deep: session to sets to exercise. Never reach for populate=* in production, since it pulls every relation and field and inflates your query cost.
In this tracker, volume is calculated as reps multiplied by weight, summed per exercise. That summed volume gives the dashboard a simple way to represent progressive overload as increasing total work rather than weight alone. The loop builds a volumeByExercise map first and only pushes the aggregated totals into volumeHistory after all sets in a session are tallied. Without that intermediate map, each individual set would become its own data point and the trend lines would jitter instead of showing a clean per-session total.
The recordMap keeps a single best-weight entry per exercise by comparing each set's weight against the stored maximum and overwriting only when the new set is heavier. The personal record logic never grows past one entry per exercise no matter how many sets a user logs.
The calculateStreak method sorts the week keys in descending order, then counts consecutive calendar weeks using differenceInCalendarWeeks, breaking the count as soon as a gap larger than one week appears.
The streak relies on calendar weeks rather than raw day counts, so two sessions in the same week count once toward the streak rather than inflating it. That is why the code keys a Set by each session's week start date: duplicate week keys collapse automatically, and the streak measures consistency across weeks instead of total sessions logged.
Step 5: Create a Custom Stats API Route
The service needs a controller to invoke it and a route to reach it. Start with the controller, which reads the authenticated user from ctx.state and delegates to the service.
// src/api/workout-session/controllers/workout-session.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController(
'api::workout-session.workout-session',
({ strapi }) => ({
async getStats(ctx) {
const user = ctx.state.user;
if (!user) {
return ctx.unauthorized('You must be logged in to view stats.');
}
const stats = await strapi
.service('api::workout-session.stats')
.getStats(user.documentId);
ctx.send(stats);
},
})
);Notice the service lookup uses strapi.service('api::workout-session.stats'). That string maps to the stats.ts file you created, not the default workout-session.ts core service.
Register the route. The 01- prefix on the filename loads it before the auto-generated core routes.
// src/api/workout-session/routes/01-stats.ts
export default {
routes: [
{
method: 'GET',
path: '/workout-sessions/stats',
handler: 'api::workout-session.workout-session.getStats',
config: {
policies: ['plugin::users-permissions.isAuthenticated'],
},
},
],
};The isAuthenticated policy rejects any request without a valid JSON Web Token (JWT), so the stats endpoint is private by default. In the Admin Panel, head to Settings, then Users & Permissions, then Roles, and grant the Authenticated role access to create and read on Workout Session, Workout Set, and Personal Record. Grant the Public role find and findOne access on Exercise so the library renders without a login.
Those Authenticated role permissions let a logged-in user create Workout Session and Workout Set records, but the records still need an explicit user/account relationship and ownership-enforcement logic to ensure they are tied to the creator's account.
The custom stats route stays gated behind the isAuthenticated policy, which prevents unauthenticated access but does not by itself guarantee that one user cannot read another user's training history through that endpoint. Each authenticated request makes the current user available from the JWT; data remains scoped per account only when queries explicitly filter by that user.
Building the Next.js 16 Frontend
The App Router lets you keep data fetching on the server inside Server Components while pushing only the interactive pieces, like charts and forms, to the client. That split keeps your Strapi API token out of the browser bundle, since the token only ever sits in server-side code. The browser receives rendered HTML and plain serializable props, never the credentials used to fetch them.
The frontend splits along the same two-tier line. Server Components render the public exercise library for search engines, and Server Actions handle authenticated workout logging. Strapi and Next.js pair well because both speak the same async data-fetching language.
Step 1: Set Up the Next.js 16 Project
Create the app with the App Router. This example uses Tailwind CSS through the --tailwind flag.
npx create-next-app@latest fitness-frontend --typescript --app --tailwind
cd fitness-frontend
npm install recharts react-is date-fnsAdd your environment variables. The public Strapi URL is exposed to the browser for image loading, while the API token stays server-side.
# .env.local
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-read-only-api-tokenTailwind v4 uses a CSS-first config, so there's no tailwind.config.js. Import it once in your global stylesheet:
/* app/globals.css */
@import "tailwindcss";Define a shared set of TypeScript types matching the flat Strapi 5 REST response. In Strapi 5, attributes sit directly on the data object with no data.attributes wrapper, and every record carries a string documentId.
// lib/types.ts
export interface StrapiImage {
id: number;
documentId: string;
name: string;
url: string;
alternativeText: string | null;
}
export interface Exercise {
id: number;
documentId: string;
name: string;
slug: string;
instructions: string | null;
muscleGroup: string;
equipment: string;
difficulty: string;
description: BlockNode[] | null;
demonstrationImages: StrapiImage[];
}
export interface BlockNode {
type: string;
children: { type: string; text: string }[];
}
export interface StrapiResponse<T> {
data: T;
meta: Record<string, unknown>;
}Step 2: Build the Public Exercise Library
The library page fetches exercises on the server, filters them by muscle group, and renders cards. Because this runs as a Server Component, the HTML reaches search engines fully formed.
// lib/strapi.ts
import { StrapiResponse, Exercise } from './types';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
export async function getExercises(
muscleGroup?: string
): Promise<Exercise[]> {
const filter = muscleGroup
? `&filters[muscleGroup][$eq]=${muscleGroup}`
: '';
const res = await fetch(
`${STRAPI_URL}/api/exercises?populate=demonstrationImages${filter}`,
{ next: { revalidate: 3600 } }
);
if (!res.ok) {
throw new Error(`Failed to fetch exercises: ${res.status}`);
}
const json: StrapiResponse<Exercise[]> = await res.json();
return json.data;
}
export async function getExerciseBySlug(
slug: string
): Promise<Exercise | null> {
const res = await fetch(
`${STRAPI_URL}/api/exercises?filters[slug][$eq]=${slug}&populate=demonstrationImages`,
{ next: { revalidate: 3600 } }
);
if (!res.ok) {
throw new Error(`Failed to fetch exercise: ${res.status}`);
}
const json: StrapiResponse<Exercise[]> = await res.json();
return json.data[0] ?? null;
}The revalidate: 3600 option caches the response for an hour, so the library stays fast without hitting Strapi on every request. The catalog page reads a muscle group filter from the URL search params.
// app/exercises/page.tsx
import Link from 'next/link';
import { getExercises } from '@/lib/strapi';
const MUSCLE_GROUPS = ['chest', 'back', 'legs', 'shoulders', 'arms', 'core'];
export default async function ExercisesPage({
searchParams,
}: {
searchParams: Promise<{ muscle?: string }>;
}) {
const { muscle } = await searchParams;
const exercises = await getExercises(muscle);
return (
<main className="mx-auto max-w-5xl p-6">
<h1 className="mb-6 text-3xl font-bold">Exercise Library</h1>
<nav className="mb-8 flex flex-wrap gap-2">
<Link
href="/exercises"
className="rounded border px-3 py-1 text-sm"
>
All
</Link>
{MUSCLE_GROUPS.map((group) => (
<Link
key={group}
href={`/exercises?muscle=${group}`}
className="rounded border px-3 py-1 text-sm capitalize"
>
{group}
</Link>
))}
</nav>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{exercises.map((exercise) => (
<Link
key={exercise.documentId}
href={`/exercises/${exercise.slug}`}
className="rounded-lg border p-4 transition hover:shadow-md"
>
<h2 className="text-lg font-semibold">{exercise.name}</h2>
<p className="mt-1 text-sm capitalize text-gray-500">
{exercise.muscleGroup} · {exercise.equipment}
</p>
</Link>
))}
</div>
</main>
);
}Individual exercise pages do the SEO heavy lifting. The generateMetadata function produces a unique title and description per exercise, and because params is a Promise in Next.js 16, you await it before reading the slug.
// app/exercises/[slug]/page.tsx
import { Metadata } from 'next';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getExerciseBySlug } from '@/lib/strapi';
type Props = {
params: Promise<{ slug: string }>;
};
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
export async function generateMetadata({
params,
}: Props): Promise<Metadata> {
const { slug } = await params;
const exercise = await getExerciseBySlug(slug);
if (!exercise) {
return { title: 'Exercise Not Found' };
}
return {
title: `${exercise.name} Form Guide`,
description: `Learn proper ${exercise.name} technique: muscle groups, equipment, and step-by-step instructions.`,
};
}
export default async function ExercisePage({ params }: Props) {
const { slug } = await params;
const exercise = await getExerciseBySlug(slug);
if (!exercise) {
notFound();
}
const cover = exercise.demonstrationImages?.[0];
return (
<main className="mx-auto max-w-3xl p-6">
<h1 className="text-3xl font-bold">{exercise.name}</h1>
<p className="mt-2 capitalize text-gray-500">
{exercise.muscleGroup} · {exercise.equipment} · {exercise.difficulty}
</p>
{cover && (
<div className="my-6">
<Image
src={`${STRAPI_URL}${cover.url}`}
alt={cover.alternativeText ?? `${exercise.name} demonstration`}
width={768}
height={480}
className="rounded-lg"
/>
</div>
)}
{exercise.instructions && (
<section className="mt-6">
<h2 className="text-xl font-semibold">How to Perform</h2>
<p className="mt-2 whitespace-pre-line text-gray-700">
{exercise.instructions}
</p>
</section>
)}
</main>
);
}The image URL combines the public Strapi base with the relative url returned by the Media Library. The flat response means you read cover.url directly, with no nested attributes object to unwrap.
Step 3: Implement Workout Logging
Logging happens through Server Actions, which run server-side code on form submission without a separate API route. First, a small auth helper reads the JWT from a cookie set at login.
// lib/auth.ts
import { cookies } from 'next/headers';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
export async function getToken(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get('jwt')?.value ?? null;
}
export async function login(
identifier: string,
password: string
): Promise<boolean> {
const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
});
if (!res.ok) {
return false;
}
const data = await res.json();
const cookieStore = await cookies();
cookieStore.set('jwt', data.jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
return true;
}The login endpoint takes identifier and password, where identifier accepts either email or username. Strapi rate-limits this route to five requests per five minutes by default, so handle 429 responses gracefully in your form UI.
Now the Server Actions for starting a session and logging sets. Each action attaches the JWT and posts to the REST API using documentId for relation connect syntax.
// app/workouts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { getToken } from '@/lib/auth';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
export async function startSession(formData: FormData) {
const token = await getToken();
if (!token) throw new Error('Not authenticated');
const name = formData.get('name') as string;
const date = formData.get('date') as string;
const res = await fetch(`${STRAPI_URL}/api/workout-sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: { name, date, status: 'in_progress' },
}),
});
if (!res.ok) {
throw new Error(`Failed to start session: ${res.status}`);
}
const { data } = await res.json();
revalidatePath('/workouts');
return data.documentId as string;
}
export async function logSet(formData: FormData) {
const token = await getToken();
if (!token) throw new Error('Not authenticated');
const sessionId = formData.get('sessionId') as string;
const exerciseId = formData.get('exerciseId') as string;
const res = await fetch(`${STRAPI_URL}/api/workout-sets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: {
setNumber: Number(formData.get('setNumber')),
reps: Number(formData.get('reps')),
weight: Number(formData.get('weight')),
restTime: Number(formData.get('restTime')),
workoutSession: { connect: [sessionId] },
exercise: { connect: [exerciseId] },
},
}),
});
if (!res.ok) {
throw new Error(`Failed to log set: ${res.status}`);
}
revalidatePath('/workouts');
}
export async function completeSession(sessionId: string) {
const token = await getToken();
if (!token) throw new Error('Not authenticated');
const res = await fetch(
`${STRAPI_URL}/api/workout-sessions/${sessionId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ data: { status: 'completed' } }),
}
);
if (!res.ok) {
throw new Error(`Failed to complete session: ${res.status}`);
}
revalidatePath('/dashboard');
}The connect syntax adds relations by documentId. Both the session URL for completion and the relation references use documentId, never the numeric id. The form that drives these actions stays simple:
// components/set-form.tsx
import { logSet } from './actions';
export function SetForm({
sessionId,
exerciseId,
}: {
sessionId: string;
exerciseId: string;
}) {
return (
<form action={logSet} className="flex flex-wrap items-end gap-3">
<input type="hidden" name="sessionId" value={sessionId} />
<input type="hidden" name="exerciseId" value={exerciseId} />
<label className="flex flex-col text-sm">
Set
<input name="setNumber" type="number" defaultValue={1} className="border p-1" />
</label>
<label className="flex flex-col text-sm">
Reps
<input name="reps" type="number" required className="border p-1" />
</label>
<label className="flex flex-col text-sm">
Weight (kg)
<input name="weight" type="number" step="0.5" required className="border p-1" />
</label>
<label className="flex flex-col text-sm">
Rest (s)
<input name="restTime" type="number" defaultValue={90} className="border p-1" />
</label>
<button type="submit" className="rounded bg-black px-4 py-2 text-white">
Log Set
</button>
</form>
);
}Step 4: Visualize Progress with Recharts
Recharts depends on browser APIs, so its components must run as Client Components with the 'use client' directive. Before installing, confirm your Recharts version is at least 2.13.0 and ensure react-is matches your React/React DOM version; later Recharts 2.15.x releases explicitly mark React 19 as a compatible peer dependency. The dashboard page itself stays a Server Component, fetches stats from your custom endpoint, and passes plain data down as props.
// components/volume-chart.tsx
'use client';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface VolumePoint {
date: string;
exercise: string;
volume: number;
}
export default function VolumeChart({ data }: { data: VolumePoint[] }) {
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="volume"
stroke="#8884d8"
dot={{ fill: '#fff' }}
activeDot={{ stroke: '#fff' }}
/>
</LineChart>
</ResponsiveContainer>
);
}The dashboard fetches the aggregated stats with the user's JWT and maps the response into chart-friendly shapes.
// app/dashboard/page.tsx
import { format, parseISO } from 'date-fns';
import VolumeChart from '@/components/volume-chart';
import { getToken } from '@/lib/auth';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
interface VolumePoint {
date: string;
exercise: string;
volume: number;
}
interface PersonalRecord {
exercise: string;
maxWeight: number;
reps: number;
dateAchieved: string;
}
interface StatsResponse {
volumeHistory: VolumePoint[];
personalRecords: PersonalRecord[];
weeklyWorkouts: number;
currentStreak: number;
}
async function getStats(token: string): Promise<StatsResponse> {
const res = await fetch(
`${STRAPI_URL}/api/workout-sessions/stats`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!res.ok) {
throw new Error(`Failed to fetch stats: ${res.status}`);
}
return res.json();
}
export default async function DashboardPage() {
const token = await getToken();
if (!token) {
return (
<main className="p-6">
<p>Please log in to view your dashboard.</p>
</main>
);
}
const stats = await getStats(token);
const chartData = stats.volumeHistory.map((point) => ({
...point,
date: format(parseISO(point.date), 'MMM d'),
}));
return (
<main className="mx-auto max-w-4xl p-6">
<h1 className="mb-6 text-3xl font-bold">Your Progress</h1>
<div className="mb-8 grid grid-cols-2 gap-4">
<div className="rounded-lg border p-4">
<p className="text-sm text-gray-500">Weekly Workouts</p>
<p className="text-2xl font-bold">{stats.weeklyWorkouts}</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-gray-500">Current Streak</p>
<p className="text-2xl font-bold">{stats.currentStreak} weeks</p>
</div>
</div>
<section className="mb-8">
<h2 className="mb-4 text-xl font-semibold">Volume Over Time</h2>
<VolumeChart data={chartData} />
</section>
<section>
<h2 className="mb-4 text-xl font-semibold">Personal Records</h2>
<ul className="divide-y">
{stats.personalRecords.map((pr) => (
<li key={pr.exercise} className="flex justify-between py-2">
<span>{pr.exercise}</span>
<span className="font-medium">
{pr.maxWeight} kg × {pr.reps} on{' '}
{format(parseISO(pr.dateAchieved), 'MMM d, yyyy')}
</span>
</li>
))}
</ul>
</section>
</main>
);
}The cache: 'no-store' option keeps stats fresh on every load, since a user expects their dashboard to reflect the workout they just logged. The Server Component handles the Strapi fetch, so authentication tokens never reach the client bundle. Only the serializable chart data crosses into the 'use client' boundary.
For exercise images, the upload follows the two-step Media Library flow. In Strapi 5 you can't upload a file and create the entry in the same request. You POST the image to /api/upload as multipart FormData, take the returned file id, then POST the exercise referencing that file.
Most fitness platforms upload demonstration images through the Admin Panel Media Library, which handles this flow for you when you drag images into the Exercise entry.
If you'd rather upload programmatically from your own UI, a small helper handles the first step.
// lib/upload.ts
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
export async function uploadExerciseImage(
file: File,
token: string
): Promise<number> {
const form = new FormData();
form.append('files', file);
const res = await fetch(`${STRAPI_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
});
if (!res.ok) {
throw new Error(`Upload failed: ${res.status}`);
}
const uploaded = await res.json();
return uploaded[0].id as number;
}The /api/upload endpoint accepts multipart/form-data with a files field and returns an array of uploaded file objects. You read the numeric id from the first element, then reference that id when creating or updating the Exercise entry's demonstrationImages relation in a second request. Do not set a Content-Type header manually here, because the browser sets the multipart boundary for you when you pass a FormData body.
Putting It All Together
Walk the full loop to confirm everything connects. Start at the public library by visiting /exercises. The page renders server-side with muscle group filters, and each card links to a detail page like /exercises/barbell-squat that carries its own metadata for search engines. No login required, which is exactly what you want for content meant to rank.
Register a user by POSTing to /api/auth/local/register with username, email, and password, then log in through your form, which stores the JWT in an httpOnly cookie. Head to /workouts, start a session with a name and date, and the startSession action returns the new session's documentId. Log several sets across different exercises through the set form, each one connecting to the session and an exercise by documentId. When you finish, call completeSession to flip the status to completed.
Open /dashboard. The Server Component hits your custom /api/workout-sessions/stats endpoint with the user's token, the stats service walks the session-set-exercise relations through the Document Service API, and you see total volume plotted over time, your current weekly streak, and a personal record for each exercise you trained.
The volume chart shows trend lines that climb as the weight you log increases, so a heavier or higher-rep session visibly bumps the line upward. The personal record list updates the moment you log a heavier set for an exercise, because the recordMap overwrites the stored maximum as soon as a new set beats it. Since every query filters by the authenticated user's documentId, the numbers on the page reflect only that account's training history and never bleed across users. Log another session next week, and the streak climbs. The data is yours, sitting in your PostgreSQL database, exportable any time.
Plan for the failure paths too. The Server Actions may throw for unexpected errors, while expected non-OK cases are typically returned as error data, so wrap calls like startSession and logSet in try/catch and surface a readable message in the form. A try/catch around logSet should reset the form's submit state so the user can correct and resubmit, and it should display the thrown status code so they can tell whether the failure was a 400 validation error or a 401 authentication problem.
The login form should catch the 429 returned by Strapi's rate limiter, which by default can be configured to return 429 after five requests per minute (or another configured limit), and show a retry message rather than a blank failure. That rate-limit message should tell the user roughly how long to wait before trying again, since the limit resets on a five-minute window. Because the dashboard fetch uses cache: 'no-store', a freshly logged set appears on the next load without any manual cache busting.
How Strapi Powers This
Strapi 5 does the structural work that would otherwise mean writing your own API layer from scratch. The Content-Type Builder defines four related models with typed fields and relational links, then auto-generates REST endpoints for each one. The Document Service API handles nested populate queries so the stats service can walk from sessions to sets to exercises in a single call.
The Media Library stores and serves exercise demonstration images with no extra storage configuration. Role-based permissions split access cleanly: the Public role opens the exercise catalog to search engines, while the Authenticated role restricts workout data to logged-in users.
The custom service and route layer gives you a place for business logic (volume aggregation, streak calculation) without abandoning the auto-generated CRUD that covers everything else. That split between generated endpoints and targeted customization is what keeps the codebase small.
Next Steps
You have a working two-tier platform. Here's where to take it:
- Deploy the backend to Strapi Cloud and the frontend to Vercel for a Git-integrated workflow that skips manual server management.
- Add a workout template and program system so users can save routines and reuse them, building on the same relational model.
- Build a rest timer Client Component that reads each set's
restTimeand counts down between sets. - Add social features like shared workouts or volume leaderboards using a
manyToManyrelation between users. - Dig deeper into the Strapi documentation and the Next.js docs for advanced caching, internationalization, and deployment patterns.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.