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:
- Input a workout
- Log sets, reps, and weight
- Save it
- Track historical data
- Compare week-to-week or lift-to-lift
And on top of that, I care about progressive overload - so when I add an exercise, I want to quickly see:
- The last time I did the exercise.
- How much weight was lifted?
- How many reps did I hit?
- Whether I’m progressing or not
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.
- Client Feature Layer →
WorkoutForm,WorkoutList - Server State Boundary → React Query
- API Layer → Next.js route handlers
- Domain Layer → Prisma relational models
- Database Layer → PostgreSQL
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:
- Fetching data from the API
- Caching server responses
- Managing loading and error states
- Synchronizing server state across components
- Automatically refetching data when mutations occur
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:
- Multiple components fetching the same data
- Duplicated loading state
- Manual refresh logic
- UI falling out of sync with the server
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:
WorkoutListsubscribes to the"workouts"queryWorkoutFormperforms a mutation to create a workout- After the mutation succeeds, the query cache is invalidated
- React Query automatically refetches the workouts list
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:
- Selecting exercises
- Logging sets, reps, and weight
- Submitting the workout
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:
- Tracking the mutation state (
pending,success,error) - Preventing duplicate submissions
- Managing error handling
- Allowing optimistic UI updates if desired
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:
- Validating request data
- Performing the nested database write
- Returning the created workout
4. Database Write via Prisma
The API route uses Prisma to perform a nested relational write.
A single request can create:
- A workout
- Multiple workout exercises
- Multiple sets per exercise
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:
- No manual list updates
- No duplicated state
- No risk of stale data
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
- Join table (
WorkoutExercise) preserves ordering - Unique constraints prevent duplicate set numbers
"fail"reps stored as1(sentinel value)- Form state uses strings
- Database uses numeric types
- Parsing happens at submission boundaries
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:
- Application layer — UI components, routes, and API handlers
- Data layer — database schema and migrations
- Shared utilities and configuration
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:
- UI components live inside
app/components - Server endpoints are implemented as Next.js route handlers inside
app/api - Server state is managed through React Query providers
- Database modeling lives inside the Prisma schema
- Shared logic is centralized in
typesandutils
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:
- Showing last performance directly inside the exercise autocomplete
- Tracking total volume over time
- Saving reusable workout templates
- Adding simple progressive overload indicators
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.