Architecting a Flexible Workout Logger in Next.js

March 1, 2026 · 12 min read

coding

Why I Built This

I wanted to build this app because so many workout trackers out there don’t quite have the feature set that I want. Many times, when I am lifting, I’m building my workout real-time, and what I want is something that removes the clutter of so many other apps while providing me flexibility in the moment without sacrificing structure. I really just want a straightforward tracker that allows me to perform basic tasks without much friction.

For my MVP, what I want is very straightforward:

And on top of that, I care about progressive overload - so when I add an exercise, I want to quickly see:


Tech Stack

With this project I really wanted to lean on my professional experience using React and TypeScript, while also taking the opportunity to learn some new technologies along the way.

At my last job I spent a good amount of time building client applications with React, TSX, and libraries like MUI to create reusable component systems that powered several internal tools. That experience gave me a solid foundation for building frontend interfaces and thinking about how to structure UI code in a maintainable way.

However, there were still parts of the modern React ecosystem that I hadn’t worked with directly. In particular, I hadn’t had much experience with frameworks like Next.js or some of the newer tooling that has become common in full-stack React applications.

So the goal with this project wasn’t just to build a workout tracker I’d actually use — it was also a chance to explore parts of the ecosystem I hadn’t used before, while still leaning on tools and patterns I already feel comfortable with.

The result is a stack that mixes familiar technologies with a few new ones, giving me the opportunity to learn while still building something practical.

The Stack at a Glance

Each piece of the stack plays a specific role — from building UI quickly, to managing server state, to modeling relational data.

Below is a short breakdown of each technology and why I chose it for this project.


Framework: Next.js (React 19)

I hadn’t used Next.js before, and since it’s one of the most widely adopted React frameworks in production applications, it felt like a good opportunity to learn it in a practical way. Building this project let me get familiar with the App Router, server/client component boundaries, and the overall Next.js ecosystem while working on a real application I actually use.


Language: TypeScript

TypeScript helps keep the project maintainable as the application grows. With multiple layers — form state, API routes, Prisma models, and UI components — strong typing makes the contracts between those layers explicit and helps catch bugs earlier during development.


UI Library: MUI v7 (@mui/material@mui/icons-material)

MUI provides a strong foundation of well-tested, accessible UI components that can be composed quickly. For this project, it allowed me to focus on the functionality of the workout logger rather than rebuilding common UI elements like tables, dialogs, inputs, and autocomplete components from scratch.


Styling: Tailwind CSS v4 + Emotion

Tailwind handles layout and utility styling, while Emotion powers MUI’s component-level styling system. Using both gives a good balance: Tailwind keeps layout fast and expressive, while MUI components maintain consistent structure and behavior.


Data Fetching: TanStack React Query v5

React Query manages the lifecycle of server state — fetching, caching, invalidation, and background updates. Instead of manually managing loading states or synchronizing lists after mutations, React Query centralizes that logic and keeps the UI consistent with the server automatically.


ORM: Prisma v6

Prisma provides a type-safe way to interact with the database while keeping the schema definition clear and explicit. Its relational modeling works particularly well for a workout tracker, where workouts, exercises, and sets are all linked together through structured relationships.


Database: PostgreSQL

PostgreSQL was chosen because it’s a mature, reliable relational database that works well with structured data models. Since workouts naturally map to relational tables (workouts → exercises → sets), Postgres provides strong constraints and querying capabilities for managing that data cleanly.


System Architecture

With the tech stack in place, the next step was thinking about how the pieces of the application should fit together.

Even though this started as a personal tool, I wanted to structure it the same way I would approach a production system — with clear boundaries between the UI, server logic, and the data layer.

At a high level, the UI handles user interaction, React Query manages server state, API routes handle backend logic, and Prisma provides the interface to the database.

This section walks through how those layers interact during a typical request, followed by the relational data model that powers the application.


High-Level Architecture

One of the most important architectural decisions in the project was introducing TanStack React Query as the boundary between the UI and the server.

Instead of letting components fetch data directly, all server data flows through React Query.

React Query is responsible for:

This creates a clear separation between UI state and server state.


Why React Query Matters Here

I wanted to avoid handling server data using useEffect in combination with local state variables

That approach usually leads to problems as the application grows:

React Query solves this by creating a centralized server state layer.

Instead of components owning the data themselves, they subscribe to queries managed by React Query’s cache.

For example:

No manual state synchronization is required.


Data Flow Through the Stack

When a user logs a workout, the request flows through each layer of the system.

A typical flow looks like this:

1. User Interaction

The user logs a workout through the WorkoutForm interface.

This includes:

At this stage, everything is local UI state.


2. React Query Mutation

When the user submits the workout, the form triggers a React Query mutation.

const createWorkout = useMutation({
  mutationFn: (data) => fetch("/api/workouts", {...})
})

React Query handles:

Instead of manually tracking submission state, the UI simply reacts to the mutation lifecycle.


3. API Request

React Query then sends the request to the Next.js API route.

This keeps network logic outside of the UI layer.

The API route is responsible for:


4. Database Write via Prisma

The API route uses Prisma to perform a nested relational write.

A single request can create:

Because the relational model is defined in Prisma, the database write remains structured and type-safe.


5. Cache Invalidation

Once the mutation succeeds, React Query invalidates the cached workouts list.

queryClient.invalidateQueries({ queryKey: ["workouts"] });

This tells React Query:

“The workouts data may be stale — refetch it.”

Any component subscribed to the "workouts" query automatically refreshes.

In this case, the WorkoutList component refetches and updates itself without any manual state updates.


6. UI Re-renders with Fresh Data

Because React Query manages the cache, the UI updates automatically when the query resolves.

This means:

The UI always reflects the current server state.


Responsibilities by Layer

Each layer in the system now has a clearly defined responsibility:

| Layer         |                    Responsibility                    |
|---------------|------------------------------------------------------|
| UI Components |             Render inputs and display data           |
| React Query   |             Manage server state lifecycle            |
| API Routes    |     Handle request validation and business logic     |
| Prisma        | Translate application logic into database operations |
| PostgreSQL    |                 Persist relational data              |

DB Model

Workout
  id           String   (cuid, PK)
  performedAt  DateTime
  notes        String?
  workoutExercises → WorkoutExercise[]

Exercise
  id           String   (cuid, PK)
  name         String   (unique)
  muscleGroup  String?
  workoutExercises → WorkoutExercise[]

WorkoutExercise  (join table with ordering)
  id           String   (cuid, PK)
  workoutId    → Workout
  exerciseId   → Exercise
  order        Int
  sets         → Set[]
  UNIQUE (workoutId, order)

Set
  id                  String   (cuid, PK)
  workoutExerciseId   → WorkoutExercise
  setNumber           Int
  weight              Float
  reps                Int      (-1 sentinel = "failure" set)
  rpe                 Float?
  notes               String?
  UNIQUE (workoutExerciseId, setNumber)

Key Decisions

This keeps the database strict while keeping the UI expressive.


Project Structure

As the application grew, organizing the project structure became just as important as the architectural decisions themselves. The goal was to keep related responsibilities grouped together while maintaining a clear separation between the UI, server logic, and database layer.

At a high level, the project is organized around three primary areas:

A condensed view of the project structure looks like this:

workout-tracker/
├── app/
│   ├── api/                 # Next.js route handlers
│   │   ├── exercises/
│   │   └── workouts/
│   │
│   ├── components/
│   │   ├── component-library/   # Reusable UI components
│   │   │   └── exercise-table/  # Responsive set logging UI
│   │   │
│   │   ├── WorkoutForm.tsx      # Workout logging interface
│   │   ├── WorkoutList.tsx      # Workout history view
│   │   ├── QueryProvider.tsx    # React Query provider
│   │   └── ThemeRegistry.tsx    # Theme + styling infrastructure
│   │
│   ├── history/             # /history route
│   ├── theme/               # MUI theme configuration
│   ├── types/               # Shared TypeScript types
│   ├── utils/               # Helper utilities
│   │
│   ├── layout.tsx           # Root layout + providers
│   └── page.tsx             # Home route
│
├── prisma/                  # Database schema & migrations
│   ├── schema.prisma
│   ├── migrations/
│   └── seed.ts
│
├── src/lib/
│   └── prisma.ts            # Prisma client singleton
│
└── public/                  # Static assets

The structure mirrors the architectural layers discussed earlier:

This keeps responsibilities well defined and prevents concerns from bleeding across layers as the application grows.


Planned Additions

There are a number of features I’d like to build on top of this foundation.

The next iterations will focus on making the application more useful during a workout while still keeping the interface minimal.

Some of the features I plan to add include:

Because the architecture separates UI, server state, and database logic, most of these features can be added without major structural changes.


Final Thoughts

The goal with this project wasn’t to build the most complex workout tracker possible.

It was to build a focused application that solves a real problem well, while also experimenting with modern tools and architecture patterns along the way.

Side projects are a great place to explore ideas that don’t always come up in day-to-day work. For me, this one was an opportunity to learn parts of the React ecosystem I hadn’t used before, while still leaning on the tools and patterns I’m comfortable with.

And perhaps most importantly — it’s an application I actually use.

Which makes building it a lot more fun.