Ezeigbo Emmanuel

How to create a fitness app with NextJS and Typescript

September 26, 2023

Ezeigbo Emmanuel

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

https://fitlord.vercel.app

Blog Post Image

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.

Blog Post Image

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;

Blog Post Image

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

Blog Post Image

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>
Blog Post Image

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
Blog Post Image

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
Blog Post Image

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
Blog Post Image

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}
/>
Blog Post Image

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

Blog Post Image

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.

Blog Post Image

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

Blog Post Image

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.

Blog Post Image

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:

Blog Post Image

next.config.js


const nextConfig = {
    images: {
        domains: ["v2.exercisedb.io"]
    }
}

module.exports = nextConfig

Restart the server and check your browser

Blog Post Image

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.

Blog Post Image

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>
  );
Blog Post Image

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.

Blog Post Image

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.

Blog Post Image

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;
Blog Post Image

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);
    };
Blog Post Image

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;
Blog Post Image

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:

Blog Post Image

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

Blog Post Image

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

Blog Post Image
Blog Post Image

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 coffee

Share post

Read more blogs

Enjoyed this article?

Leave a comment


Comments


Be the first to comment