
How to create a fitness app with NextJS and Typescript
September 26, 2023

Ezeigbo Emmanuel
In today's fast-paced world, staying healthy and fit is a priority for many of us. In this tutorial, we will embark on a journey to develop a cutting-edge fitness application that leverages the power of Next.js and TypeScript.
Typescript
Take a look at what we are going to build

After reading this article, you will have a complete fitness app in your portfolio.
Integrated with all modern features like:
Single Page View and Search functionality
We are going to make use of APIs, so if you have or haven't used an API before, this tutorial will leverage your API skills.
Installing Dependencies
To get started, we have to install NextJS and Typescript.
To install these dependencies,
just type:
terminal
npx create-next-app@latest
in your terminal
After pressing enter, you'll be asked some questions.

Then automatically, NextJS 13 and Typescript are installed.
NOTE: In this tutorial, we are not going to use tailwind CSS, we are going to use normal CSS instead. We are also going to use the new NextJS App router.
After installation, cd to your app name
terminal
cd my-app
then
terminal
npm run dev
to run on the local server.
If you've done everything correctly, you should see this;

To start working on our app, head over to src/app/page.tsx then clear out everything leaving
app/page.tsx
export default function Home() {
return (
<main>
</main>
)
}
behind.
Folder Structure
We are going to delete the page.modules.css and globals.css(make sure to remove where they are imported too - app/layout.tsx)
Create a styles.css file, and we will put all our CSS code there.
Import it in app/page.tsx and app/layout.tsx.
app/page.tsx & app/layout.tsx
import "./styles.css"
Our site is empty, so let's create our components.
In the src folder, create a new folder - components, inside components, create the following files: Navbar.tsx, Banner.tsx, Exercises.tsx and Footer.tsx

The components we created are just the base component, we are going to add some as we advance in this tutorial.
Add your component skeleton code to all your components, giving them their respective names:
Navbar.tsx
import React from 'react'
const Navbar = () => {
return (
<div>Navbar</div>
)
}
export default Navbar
The Navbar and Footer components will be placed above and below the children in the app/layout.tsx file respectively because they are to be present on every page of the app.
app/layout.tsx
import Navbar from "@/components/Navbar"
import Footer from "@/components/Footer"
<body className={inter.className}>
<Navbar />
{children}
<Footer />
</body>

We are going to work on the components in that order.
Navbar Component
Navbar.tsx
import React from 'react'
import Link from 'next/link'
const Navbar = () => {
return (
<div>
<div><Link href="/">FitLord</Link></div>
<div>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
</div>
</div>
)
}
export default Navbar

Doesn't look nice at all, let's style it a bit.
For this tutorial to be readable and not extremely long, I won't talk about the CSS aspect. You can just copy mine and then change it later.]
Full CSS file - Github
copy and paste the code in your styles.css file.
Now let's add the classNames
Navbar.tsx
import React from 'react'
import Link from 'next/link'
const Navbar = () => {
return (
<div className="navbar-wrapper">
<div className="logo"><Link href="/" className="nav-link">FitLord</Link></div>
<div className="nav-links">
<Link href="/" className="nav-link">Home</Link>
<Link href="/about" className="nav-link">About</Link>
</div>
</div>
)
}
export default Navbar

Looking Better :)
Banner Component
The banner will contain an image under a dark transparent background with a search bar and a button.
To add our image, we will create an assets folder in the src directory:
src/assets/image/fitnessimg.jpg
To get the image - Github
Banner.tsx
import Image from 'next/image'
import React from 'react'
import bannerImage from "../assets/image/fitnessimg.jpg";
const Banner = () => {
return (
<div className="banner">
<Image src={bannerImage} alt="banner-img" fill className="banner-img" />
<div>
<p className="banner-text">
GET A HEALTHY BODY WITH THE PERFECT EXERCISES
</p>
</div>
<div className="banner-search">
<input
type="text"
className="banner-input"
placeholder="Search Exercises..."
/>
<button className="banner-btn">
Search
</button>
</div>
</div>
)
}
export default Banner

This is just basic jsx.
Search Functionality
Right now, our search bar does nothing so we need to add the functionalities.
First, we need to create a search state to handle the input change.
Banner.tsx
"use client"
...
const Banner = () => {
const [search, setSearch] = useState("")
...
Note that useState is a client term, so we have to add "use client" to the top of our component.
After that, we are to create a handleChange function to track the changes in the input field:
Banner.tsx
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value.toLowerCase());
console.log(search)
};
Then add it to the onChange event listener. The value should also be set to the search state:
Banner.tsx
<input
type="text"
className="banner-input"
placeholder="Search Exercises..."
onChange={handleChange}
value={search}
/>

The handleChange function works!
Now what we want is that when a user clicks the button, the search state will be used to query data from an API.
Let's create an onClick function first.
Banner.tsx
const handleClick = async () => {
} //async function because we are going to fetch data here
then add it to the button
Banner.tsx
<button className="banner-btn" onClick={handleClick}>
Search
</button>
To continue working on our search functionality, we will have to get our data from ExerciseDB API, so let's head over to rapidapi.com

Log in then subscribe to the Basic API (it's free).
After subscribing, we need to create a special fetchData function.
Go to the src folder and create a utility folder with fetchData.ts in it:
src/utility/fetchData.ts
fetchData.ts
export const fetchData = async (url: string, options: object) => {
const response = await fetch(url, options)
const data = await response.json()
return data;
}
Code Explanation
We just created an async fetchData function (async because we are fetching the data from an API) that accepts two parameters, a url which is going to be a string and options which is an object to be gotten from RapidApi.
Then we are going to await a response using the inbuilt Fetch API also passing the url and options gotten from the fetchData function.
data is just a variable that awaits and converts the response to JSON format which we will then return.
The next thing to do is to go to RapidAPI and then copy the options.

copy and paste the options in your fetchData.ts file
(change the name to exerciseOptions) we will also have to export it because we will use it in different components.
fetchData.ts
export const exerciseOptions = {
method: 'GET',
...
You can see that our API key is not protected, so let's protect it by adding a .env.local file to the root folder.
my-app/.env.local
NEXT_PUBLIC_RAPID_API_KEY=yourapikey
replace "yourapikey" with your API key
then go back to fetchData.ts and change your key to:
fetchData.ts
...
headers: {
'X-RapidAPI-Key': process.env.NEXT_PUBLIC_RAPID_API_KEY,
'X-RapidAPI-Host': 'exercisedb.p.rapidapi.com'
}
};
...
Your key is almost protected, I will show you how to protect it better when we want to deploy it.
Save your file and go back to your Banner.tsx component
In our handleClick function, we will check if the user has typed something or not.
Banner.tsx
import { exerciseOptions, fetchData } from '@/utility/fetchData';
...
const handleClick = async () => {
if(search){
const exerciseData = await fetchData("https://exercisedb.p.rapidapi.com/exercises", exerciseOptions)
console.log(exerciseData)
}
}
Save your file and test the search button

You can see that the data is present but it is not based on the search term.
So what we will do next is to filter the data based on the search term.
Banner.tsx
const searchedExercises = exerciseData.filter(
(exercise) =>
exercise.name.toLowerCase().includes(search) ||
exercise.target.toLowerCase().includes(search) ||
exercise.equipment.toLowerCase().includes(search) ||
exercise.bodyPart.toLowerCase().includes(search)
);
We want to filter based on the name, target, equipment, or body part.
We are getting an error - Parameter 'exercise' implicitly has an 'any' type.
That means that we need to create a type for our exercise parameter.
Create a typings.d.ts in your root folder
then add this code:
typings.d.ts
interface Exercises {
bodyParts: string[];
bodyPart: string;
equipment: string;
gifUrl: string;
id: string;
name: string;
target: string;
}
This is the type of data that we are expecting.
Back to our Banner.tsx, we can now specify the type
Banner.tsx
(exercise: Exercises) =>
the error is gone.
If you console.log() the searchedExercises, you will see only the data that matches the search term.
Displaying the Exercises
To display our exercises, we have to store the searchedExercises in a state, this state is going to be used in both Banner.tsx and Exercises.tsx which means that our state will have to be in the mother component so that it can be passed as a prop to both Banner.tsx and Exercises.tsx component.
Our mother component is page.tsx, so we will declare our state there.
page.tsx
"use client";
import { useState } from "react";
export default function Home() {
const [exercises, setExercises] = useState([]);
return (
...
Since we only want to set the exercises state to be searchedExercises, we will only pass setExercises as a prop to the Banner.tsx component.
page.tsx
<main>
<Banner setExercises={setExercises} />
<Exercises />
</main>
Then we will accept the prop in Banner.tsx
Banner.tsx
const Banner = ({setExercises}) => {
...
}
Now we have an error - Binding element 'setExercises' implicitly has an 'any' type. which means that we need to create a prop type for setExercises.
Banner.tsx
type Props = {
setExercises: (value: any) => void;
};
const Banner = ({setExercises}: Props) => {
...
}
Inside our handleClick function, we will now set the state to be searchedExercises
Banner.tsx
const handleClick = async () => {
if(search){
...
setExercises(searchedExercises)
}
}
Let's go back to app/page.tsx to pass our exercises state to Exercises.tsx component for us to display the data to the users.
page.tsx
<main>
<Banner setExercises={setExercises} />
<Exercises exercises={exercises} />
</main>
Accept the prop in Exercises.tsx
Exercises.tsx
const Exercises = ({exercises}) => {
return (
...
You'll still get that same error - Binding element 'exercises' implicitly has an 'any' type.
This means that we also need to create a prop type for exercises:
Exercises.tsx
type Props = {
exercises: Exercises[];
}
const Exercises = ({exercises}: Props) => {
...
}
exercises is just an array of Exercises.
Since exercises is an array of Exercises, to get the individual data, we will need to map over it.
Exercises.tsx
return (
<div className="search-container">
<p className="search-text">Exercises Results</p>
<div className="searched-exercises">
{exercises.map((exercise) => (
<p>{exercise.name}</p>
))}
</div>
</div>
);
We just mapped through exercises to display each exercises name.

We will need more than the names, so we will create a new component (ExerciseCard.tsx) to display more information.
After creating the ExerciseCard.tsx component, we are going to add it to Exercises.tsx component and then pass exercise as a prop.
Exercises.tsx
<div className="searched-exercises">
{exercises.map((exercise) => (
<ExerciseCard exercise={exercise} />
))}
</div>
Let's go to the ExerciseCard.tsx component and accept the prop.
ExerciseCard.tsx
type Props = {
exercise: Exercises;
}
const ExerciseCard = ({exercise}: Props) => {
return (
<Link href="/" className="exercise-card">
<Image src={exercise.gifUrl} alt={exercise.name} width={330} height={330} />
<p className="exercise-card-name">{exercise.name}</p>
</Link>
)
}
If you save the file and check your browser, you'll see an error:

next.config.js
const nextConfig = {
images: {
domains: ["v2.exercisedb.io"]
}
}
module.exports = nextConfig
Restart the server and check your browser

Looks nice :)
Right now, no exercises will show until a search term is entered, we don't want that so let's work on that.
Categories component
We'll start by creating a Categories.tsx component
The categories will be based on the body parts which means that we need to fetch the data based on body parts and save it in a state.
The categories are going to be based on the body parts which means that we will have to fetch it from the API and save it in a state:
Categories.tsx
const Categories = () => {
const [bodyParts, setBodyParts] = useState<Exercises["bodyParts"]>([]);
useEffect(() => {
const fetchExercisesData = async () => {
const bodyPartsData = await fetchData(
"https://exercisedb.p.rapidapi.com/exercises/bodyPartList",
exerciseOptions
);
setBodyParts(["all", ...bodyPartsData]);
};
fetchExercisesData();
}, []);
...
Code Explanation
We used the useEffect hook to avoid infinite fetching, the first thing we did was to create a state where the data will be stored, <Exercises["bodyParts"]> is used as the type because bodyPartsData is an array of strings. We then used the spread operator to add all the bodyPartsData including the string 'all'.
Now let's map through to display each bodyPart
Categories.tsx
<div>
{bodyParts.map((bodyPart) => <p>{bodyPart}</p>)}
</div>
Don't forget to add the component to the page.tsx immediately under the Banner.tsx component.

We got the data, now let's style it a bit.
Categories.tsx
return (
<div className="exercises-wrapper">
<p className="exercise-text">Different BodyParts, Different Exercises</p>
<div className="dropdown">
<button className="dropbtn">Categories</button>
<div className="dropdown-content">
{bodyParts.map((bodyPart) => (
<p>{bodyPart}</p>
))}
</div>
</div>
</div>
);

Looks better :)
Displaying category results
To display exercises based on their category, we have to create a link between our Categories.tsx and Exercises.tsx components, and the only way to do this is in the mother component(page.tsx)
We'll create another state and set 'all' as default.
page.tsx
export default function Home() {
const [bodyPart, setBodyPart] = useState("all");
...
We want the body part to change when any category is clicked which means that we need to pass setBodyPart as a prop in the Categories component.
page.tsx
<Categories setBodyPart={setBodyPart} />
Go back to the Categories component and accept the prop.
Categories.tsx
type Props = {
setBodyPart: (value: string) => void;
}
const Categories = ({setBodyPart}: Props) => {
...
Since we want it to change when clicked, we will use the onClick event handler.
Categories.tsx
{bodyParts.map((bodyPart) => (
<p onClick={() => setBodyPart(bodyPart)}>{bodyPart}</p>
))}
Now, bodyPart will change depending on what was clicked. We can now use this bodyPart state to fetch data in the Exercises.tsx component.
Go back to the page.tsx to pass the prop to Exercises.tsx
We want the exercises state to also change depending on the category clicked, which means that we will also pass setExercises as a prop.
page.tsx
<Exercises exercises={exercises} setExercises={setExercises} bodyPart={bodyPart} />
Head over back to Exercises.tsx to accept those props
Exercises.tsx
type Props = {
exercises: Exercises[];
bodyPart: string;
setExercises: (value: any) => void;
};
const Exercises = ({ exercises, setExercises, bodyPart }: Props) => {
return (
...
The next thing to do is to fetch data based on the bodyPart:
Exercises.tsx
const Exercises = ({ exercises, setExercises, bodyPart }: Props) => {
useEffect(() => {
const fetchExercisesData = async () => {
let exercisesData = [];
if (bodyPart === "all") {
exercisesData = await fetchData(
"https://exercisedb.p.rapidapi.com/exercises",
exerciseOptions
);
}else {
exercisesData = await fetchData(
`https://exercisedb.p.rapidapi.com/exercises/bodyPart/${bodyPart}`,
exerciseOptions
);
}
setExercises(exercisesData)
};
fetchExercisesData();
}, [bodyPart]);
...
Code Explanation
We first created an empty array variable(exerciseData).
If the bodyPart is 'all' (it is already set to all by default), exerciseData will be all the data gotten from the API. If the bodyPart is something else, exerciseData will be the data gotten from the specific bodyPart. Then we finally setExercises to exerciseData.
bodyPart is set as a dependency because we want the useEffect hook to run each time the bodyPart is changed.

Nice, you can see the exercises immediately without searching and can change them depending on the category clicked.
Single Page View
This is a very interesting part, we want each exercise card to link to a new page. Let's go to ExerciseCard.tsx and change the Link href to:
ExerciseCard.tsx
<Link href={`/exercise/${exercise.id}`} className="exercise-card">
You can see the link structure - /exercise/[id]
Let's create that. Go to your app folder and create a new folder - exercise
then create another folder(a dynamic one) - [id]
then create the page.tsx
It should look like this - app/exercise/[id]/page.tsx
exercise/[id]/page.tsx
import React from 'react'
const ExercisePage = () => {
return (
<div>ExercisePage</div>
)
}
export default ExercisePage
On this page.tsx, we are going to fetch data based on the id, let's get the id first:
[id]/page.tsx
import React from 'react'
const ExercisePage = ({ params }: { params: { id: string } }) => {
return (
<div>ExercisePage: {params.id}</div>
)
}
export default ExercisePage
Read the official documentation to know more about dynamic routing.
You'll get an error, just add "use client" and the error should be gone.

The id is gotten successfully, now let's fetch data based on that id.
[id]/page.tsx
const ExercisePage = ({ params }: { params: { id: string } }) => {
useEffect(() => {
const fetchExercisesData = async () => {
const exerciseDetailData = await fetchData(
`https://exercisedb.p.rapidapi.com/exercises/exercise/${params.id}`,
exerciseOptions
);
console.log("exerciseDetail", exerciseDetailData);
};
fetchExercisesData();
}, [params.id]);
...
We have gotten the data, so we need to store it in a state.
First, let's create an interface for the exercise data type:
typings.d.ts
interface ExerciseDetail {
bodyPart: string;
equipment: string;
gifUrl: string;
id: string;
name: string;
target: string;
instructions: string[];
}
We will use this as a type for the state we are about to create.
[id]/page.tsx
const [exerciseDetail, setExerciseDetail] = useState<ExerciseDetail>({
bodyPart: "",
equipment: "",
gifUrl: "",
id: "",
name: "",
target: "",
instructions: [""],
});
Now setExerciseDetail to exerciseDetailData.
The data is now stored in the exerciseDetail state. To display this data, we will have to create another component - ExerciseDetail.tsx, then pass exerciseDetail as a prop.
[id]/page.tsx
return(
<ExerciseDetail exerciseDetail={exerciseDetail} />
)
Go to ExerciseDetail to accept the prop.
ExerciseDetail.tsx
import Image from "next/image";
import React from "react";
type Props = {
exerciseDetail: ExerciseDetail;
};
const ExerciseDetail = ({ exerciseDetail }: Props) => {
return (
<div className="exercise-detail">
<Image
src={exerciseDetail.gifUrl}
alt={exerciseDetail.name}
width={729}
height={742}
className="exercise-detail-img"
/>
<div className="exercise-detail-text">
<p className="exercise-detail-name">{exerciseDetail.name}</p>
<p style={{ textDecoration: "underline" }}>Instruction</p>
<p className="exercise-detail-description">
{exerciseDetail.instructions}
</p>
</div>
</div>
);
};
export default ExerciseDetail;

Congratulations! if you made it this far! :)
Getting related exercise videos
We want to show related videos in a single-page view. To do that, we will need another API, - Youtube Search and Download API.
Subscribe and copy the options and paste them into fetchData.ts like we did before.
fetchData.ts
export const youtubeOptions = {
method: "GET",
headers: {
"X-RapidAPI-Key": process.env.NEXT_PUBLIC_RAPID_API_KEY,
"X-RapidAPI-Host": "youtube-search-and-download.p.rapidapi.com",
},
};
Go back to [id]/page.tsx to fetch the YouTube data.
[id]/page.tsx
const fetchExercisesData = async () => {
const exerciseDetailData = await fetchData(
`https://exercisedb.p.rapidapi.com/exercises/exercise/${params.id}`,
exerciseOptions
);
console.log("exerciseDetail", exerciseDetailData);
setExerciseDetail(exerciseDetailData)
const youtubeDetailData = await fetchData(
`https://youtube-search-and-download.p.rapidapi.com/search?query=${exerciseDetailData.name}`,
youtubeOptions
);
console.log("youtubeDetail", youtubeDetailData.contents);
};

We need to store this data in a state. Let's create the type first:
typings.d.ts
type YoutubeDetail = {
video: Video;
};
interface Video {
channelId: string;
channelName: string;
description: string;
lengthText: string;
publishedTimeText: string;
title: string;
videoId: string;
viewCountText: string;
thumbnails: Thumbnails[];
}
interface Thumbnails {
height: number;
url: string;
width: number;
}
Back to [id]/page.tsx
add the state:
[id]/page.tsx
const [youtubeDetail, setYoutubeDetail] = useState<YoutubeDetail[]>([]);
Then in the useEffect hook, setYoutubeDetail to the youtubeDetailData.contents
[id]/page.tsx
setYoutubeDetail(youtubeDetailData.contents)
Just like we did for ExerciseDetail, we are also going to create a new component (YoutubeVideos.tsx) and pass the youtubeDetail as a prop.
[id]/page.tsx
return (
<div>
<ExerciseDetail exerciseDetail={exerciseDetail} />
<YoutubeVideos youtubeDetail={youtubeDetail} />
</div>
);
Accept the prop in the YoutubeVideos.tsx component.
YoutubeVideos.tsx
import Link from "next/link";
import React from "react";
type Props = {
youtubeDetail: YoutubeDetail[];
};
const YoutubeVideos = ({ youtubeDetail }: Props) => {
return (
<div>
<p style={{textTransform: 'capitalize', fontSize: '25px', textAlign: 'center'}}>Related Exercise Videos</p>
<div className="exercise-videos">
{youtubeDetail?.slice(0, 6).map((item, index) => (
<Link
key={index}
href={`https://www.youtube.com/watch?v=${item.video.videoId}`}
target="_blank"
rel="noreferrer"
className="exercise-video"
>
<img src={item.video.thumbnails[0].url} alt={item.video.title} />
<div>
<p>{item.video.title}</p>
<p style={{ color: "#4F4C4C", marginTop: "10px" }}>
{item.video.channelName}
</p>
</div>
</Link>
))}
</div>
</div>
);
};
export default YoutubeVideos;

Congratulations! You are done with the project.
Work on the footer and About page on your own.
Bonus Content
When you console.log() exerciseData, you will see that you get only 10 results, that is because the limit is 10. To change that, add ?limit=10 (change 10 to whatever number you want) to the url when requesting the data, for example:
exercisesData = await fetchData(
'https://exercisedb.p.rapidapi.com/exercises?limit=20', //I will get 20 results
exerciseOptions
);
Deployment
We are going to deploy our application to vercel[vercel.com]
Add a new project:

Then import it from GitHub(assuming you've pushed it already)

Make sure you click on the environment variables to add our API key


Click Deploy and Congratulations.
🚀 If you found value in this blog post and want to see more quality coding content, please consider supporting me with a virtual coffee ☕. Your support helps me keep creating and sharing knowledge with the coding community. It's the small gestures that make a big difference. Thank you!
☕Buy me a coffeeShare post
Read more blogs

October 3, 2023
How to convert a NextJS 13 app to a Progressive Web App (PWA)
Progressive Web Apps have gained significant traction in recent years due to their ability to combine the best of web and mobile app experiences. They are responsive and available on a variety of devices, and they provide offline access, quick load times, and a smooth user interface. This tutorial teaches how to convert your normal NextJS app to a Progressive Web App.
Read Post

August 19, 2023
How to build a blog with NextJs 13, Typescript and Tailwind CSS
Creating a blog in 2023 has never been so easy, sanity.io together with NextJS 13 and typescript does wonders. In this tutorial, I am going to share my knowledge on how I created this blog.
Read Post
Comments
Be the first to comment