Get Cooking with Next.js: A Step-by-Step Guide to Crafting a Recipe Finder App (Part 2)

Uci Lasmana
15 min readJun 23, 2024

--

In Part 1 of this tutorial, our focus is on setting up the app, understanding how to combine parallel routes and intercepting routes, and learning how to fetch data on the server and on the client. And now, in Part 2, we will focus on creating the recipe collection section, the List component, an infinite scrolling function, a scroll-to-top button, the recipe details page, and the Hero section.

If you missed the Part 1 of this tutorial you can visit this article, Get Cooking with Next.js: A Step-by-Step Guide to Crafting a Recipe Finder App (Part 1)

You can check the app by visiting this link, recipefinder-nextjs.vercel.app.

Recipe Collection Section

In this section, we display all the recipes data from TheMealDB API. The problem is the API doesn’t provide an endpoint to retrieve all recipes at once. So, to collect all the recipes, we will fetch recipes data from every area provided by the API.

components/recipeCollection.js

import useSWR from 'swr'
import {fetchWithAreas} from "../lib/fetcher";
import { HeadingSkeleton, ListSkeleton } from '../components/skeleton';
import List from './recipeList';
import { useEffect, useState,} from 'react';

const Recipes= () => {

const { data, isLoading } = useSWR('filter.php?a', fetchWithAreas)

const [recipeList, setRecipeList]=useState([])

useEffect(()=>{
if(!isLoading)
{
setRecipeList(data)
}
},[isLoading])

if(isLoading)
{
return (
<>
<HeadingSkeleton/>
<ListSkeleton totalSkeleton={12} />
</>
);
}

const totalRecipes= recipeList.length
return (
<div className='mx-6 sm:mx-8 flex flex-col gap-6'>
<div className='flex gap-2'>
<span className='font-jost font-semibold text-gray-800 text-base md:text-lg '>Recipe Collection</span>
</div>
{totalRecipes>0 &&
<List recipeList={recipeList} totalRecipes={totalRecipes}/>
}</div>
)

}
export default Recipes
  • To retrieve recipes data based on the area, we need to fetch it from the ‘filter.php?a’ endpoint using the useSWR hook.
  • The endpoint will be the key of SWR, and the fetchWithAreas function accepts this key, returning data and isLoading based on the status of the request.

lib/fetcher.js

const baseURL='https://www.themealdb.com/api/json/v1/1/'

export const fetcher = async(parameter) =>{
try{
const response = await fetch(`${baseURL}/${parameter}`)
const data = await response.json()
return data
}
catch (error){
throw error
}
}

export const fetchWithAreas = async(url) =>{
try{
return fetcher(`list.php?a=list`)
.then(areas => {
const promises = areas.meals.map(area => {
return fetcher(`${url}=${area.strArea}`)
.then(recipeListResponse => recipeListResponse.meals);
});
return Promise.all(promises);
})
.then(recipeLists => {
return recipeLists.flat();
});

}
catch (error){
throw error
}
}
  • The fetchWithAreas function is an asynchronous function that fetches the areas list data from the list.php?a=list endpoint using the fetcher function, then uses this data to fetch recipes based on the area.
  • After successfully fetching the areas list data, we map through the area list data inside the meals property and use the fetcher function again to retrieve recipes data based on the area.
  • Here, we create a promises variable to store the new array returned by the map function.
  • The map function iterates over each area in the areas.meals array and fetches data for that specific area using fetcher(url={area.strArea}). The result of each fetching process is also an array, recipeListResponse.meals.
  • So essentially, the promises array contains arrays, where each inner array corresponds to the fetched data for a specific area.
  • However, the promises array still contains promise objects that haven’t resolved yet. We need to use Promise.all(promises) to execute all these promises in parallel. This ensures that all the fetch requests for different areas happen concurrently, improving performance.

The fetch() method is promise-based, it returns a Promise object that represents the response to the request, whether it is successful or not.

If you want to learn more about the fetch() method you can visit this article, “What is AJAX, XMLHttpRequest, fetch(), and Promise?

Here is the difference between the fetcher and the fetchWithAreas functions:

In the fetcher function, we use async and await to fetch data. The await keyword pauses execution until the promise resolves (or rejects).

Using await means we execute requests sequentially (one after the other). This means that the second promise won’t start until the first one completes. It’s less efficient if we have multiple promises that can be executed concurrently.

In the fetchWithAreas function, we use async and Promise.all to fetch data. We used Promise.all over await, because we fetch data from multiple endpoints. With Promise.all we can execute the requests concurrently, we don’t need to execute the requests sequentially. This significantly improves performance by reducing the overall execution time.

  • Promise.all waits for all of promises to resolve (or any to reject) before continuing.

If any promise rejects (encounters an error), the entire Promise.all operation rejects with the error from the first rejected promise. It’s important to handle errors appropriately when using Promise.all.

Since our fetcher function already includes a try/catch block to handle errors during fetching, we don’t need to use the block anymore when fetching recipes data based on area.

  • Once all promises are resolved, we get an array of arrays (each containing recipes for a specific area). That’s why we need to flatten the array into a single array of recipes data.
  • But when the data is still loading (when isLoading is true), the HeadingSkeleton and ListSkeleton components will be displayed to represent the process. You can pass a prop to determine how many list skeletons will be shown.
const { data, isLoading } = useSWR('filter.php?a', fetchWithAreas)

const [recipeList, setRecipeList]=useState([])

useEffect(()=>{
if(!isLoading)
{
setRecipeList(data)
}
},[isLoading])

if(isLoading)
{
return (
<>
<HeadingSkeleton/>
<ListSkeleton totalSkeleton={12} />
</>
);
}

const totalRecipes= recipeList.length
  • When the recipes data become available, we update the recipeList state with the fetched recipe data.
  • After that, we calculate the total number of recipes based on the length of the recipeList array.
  • If the totalRecipes are greater than zero, we pass the recipeList and the totalRecipes as props to the List Component. Then the List Component will be rendered.

After we’re done with the Recipes component, we import the component into our main page.js

src/app/page.js

'use client'
import CategoriesList from "./components/categoriesList";
import Recipes from "./components/recipeCollection";
export default function Home() {
return (
<main className="w-full h-full flex flex-col gap-6 md:gap-8">
<CategoriesList/>
<Recipes/>
</main>
);
}

List Component

components/recipeList.js

This List component is a reusable component that used to display all of the recipe data fetched from the API. The component receives two props, recipeList (an array of recipe data) and totalRecipes (the total count of recipes).

'use client'
import Link from 'next/link';
import Loading from '../../../public/loading.gif'
import Image from "next/image";
import React, { useState, useEffect, useRef } from 'react';
import { useInView } from "react-intersection-observer";

const itemsPerLoad=24

const List= ({recipeList, totalRecipes}) => {

const { ref, inView} = useInView();
const [recipes, setRecipes] = useState([])
const [hasMoreRecipes, setHasMoreRecipes] = useState(true);
const timeoutId=useRef(null)

useEffect(() => {
if(inView){
if(totalRecipes<itemsPerLoad) {
setRecipes(recipeList)
setHasMoreRecipes(false)
}
else{
if (timeoutId.current) {
// if the user continues to scroll
// a new timeout may be set before the previous one completes.
// this ensures that only the most recent timeout
// (the last user action) triggers the recipe loading
clearTimeout(timeoutId);
}
timeoutId.current = setTimeout(() => {
const startIndex = recipes.length;
const nextRecipes = recipeList.slice(startIndex, startIndex + itemsPerLoad);
setRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]);
}, 1000);

if(recipes.length===totalRecipes){
setHasMoreRecipes(false)
}
}
}
},[inView]);

return (
<>
<div className='grid xs:grid-cols-2 md:grid-cols-3 xl-1:grid-cols-4 content-center gap-x-4 md:gap-x-6 lg:gap-x-9 gap-y-6 md:gap-y-9 w-full justify-items-stretch'>
{recipes.map((recipe, key) => (
<div key={key}>
<Link href={`/recipes/${recipe.idMeal}`} key={key}>
<div className='w-full flex flex-col justify-center items-center gap-4 sm:gap-6 text-center h-fit cursor-pointer'>
<div className="relative h-44 md:h-52 w-full lg:h-56 border border-gray-50 bg-white rounded-md shadow">
<Image
src={recipe.strMealThumb}
alt={recipe.strMeal}
fill={true}
sizes="25vw"
style={{
objectFit: 'cover',
padding:'7px',}}
/>
</div>
<div className='h-1/4 font-medium font-catamaran text-sm md:text-base text-gray-700 capitalize'>{recipe.strMeal}</div>
</div>
</Link>
</div>
))}
</div>
<div className='pt-8 pb-3 flex flex-col gap-4'>
{hasMoreRecipes&&
(<div className="flex justify-center" ref={ref}>
<Image src={Loading} alt="loading" height={70} sizes={70} />
</div>)}
<p className='text-center text-zinc-500 font-jost font-medium'>
-------- Showing {recipes.length} Recipes -------
</p>
</div>
</>
)

}
export default List

Since we used the List component to display all of the recipe data, we need to implement infinite scrolling for this component. Showing all the data at once would be overwhelming, so it better to set an initial batch of recipes first then the rest of recipes will load dynamically as users scroll down.

Infinite Scrolling

This is how we make this infinite scrolling scenario happen:

  • Use the useInView hook to detect when an element enters or leaves the viewport.
  • Extract the ref and inView values from the useInView hook.
  • Define how much data will be loaded, set itemsPerLoad to 24.
  • Create recipes state to keep the list of recipes to be displayed.
  • Create a hasMoreRecipes state to track whether there are more recipes to load.
  • Create timeoutId ref to store the timeout ID for the delay time to load the next data.

With ref we can keep information that we don’t want to trigger new renders, unlike the states.

  • Assign the ref to the DOM element we want to monitor. If hasMoreRecipes is true, the DOM element will be rendered.
  {hasMoreRecipes&& (<div className="flex justify-center" ref={ref}>
<Image src={Loading} alt="loading" height={70} sizes={70} />
</div>)}
  • When the DOM element enters the viewport, the inView status will be true, and will be false when it leaves.
  • The useEffect hook runs everytime the inView changes.
  • If totalRecipes is less than the itemsPerLoad, it sets the entire recipeList as recipes and disables further loading by set setHasMoreRecipes(false) .
  • Otherwise, we clear any existing timeout, then sets a new timeout to load the next batch of recipes after 1000 milliseconds.
timeoutId.current = setTimeout(() => {
const startIndex = recipes.length;
const nextRecipes = recipeList.slice(startIndex, startIndex + itemsPerLoad);
setRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]);
}, 1000);

if(recipes.length===totalRecipes){
setHasMoreRecipes(false)
}
  • Since TheMealDB API doesn’t provide pagination, so all we can do is retrieve all of the data, then slice() it to get the next batch of recipes (nextRecipes) starting from startIndex.
  • Updates recipes with the next batch, setRecipes((prevRecipes) => […prevRecipes, …nextRecipes]). We append the new recipes to the existing list by using the spread operator.
  • Next, we check if the total number of loaded recipes (recipes.length) is equal to the total number of recipes available (totalRecipes).
  • If all recipes have been loaded, we disable further loading by calling setHasMoreRecipes(false).

Recipe Details Page

recipes/[id]/page.js

'use client'
import BackButton from '@/app/components/backButton'
import { DetailSkeleton } from '@/app/components/skeleton'
import { fetcher } from '@/app/lib/fetcher'
import Image from "next/image";
import { useEffect, useState } from 'react'
import useSWR from 'swr'

const Details = ({params}) => {

const {id} = params
const {data, isLoading} = useSWR(`lookup.php?i=${id}`, fetcher)
const [ingredients, setIngredients] = useState([])

useEffect(() => {
if(document.body.style.overflow==='hidden')
{
//If this details page is rendered after users click the recipe
// from the modal component, then we need to set the overflow style
// of document.body to auto, allowing users to scroll the page.

document.body.style.overflow='auto'
}
}, []);

useEffect(()=>{
if(!isLoading)
{
const ingredientsValue=[]
for(let i = 1; i<=20;i++)
{
const ingredientName=details[`strIngredient${i}`]
const ingredientMeasure = details[`strMeasure${i}`];
if(ingredientName)
{

ingredientsValue.push([ingredientName, ingredientMeasure]);
}

}
setIngredients(ingredientsValue)
console.log(details)
}
}, [isLoading])

if (isLoading) {
return (
<div className="flex justify-center w-full">
<DetailSkeleton/>
</div>
)
}
const [details]=data.meals

return (
<>
<BackButton/>
<div className='w-full h-full px-6 md:px-8 flex justify-center items-center '>
<div className=' w-[95%] lg:w-full flex flex-col gap-8 justify-center items-center'>
<h4 className=' font-jost lg:w-fit text-center px-3 py-2 bg-white text-lg sm:text-xl md:text-2xl lg:text-3xl shadow rounded-xl capitalize text-red-700 font-bold'>{details.strMeal}</h4>
<div className='flex flex-col lg:flex-row justify-evenly items-center lg:items-start gap-8'>
<div className="relative h-86 w-full md:h-100 lg:w-120 lg:h-120 border border-gray-50 bg-white rounded-lg shadow">
<Image
src={details.strMealThumb}
alt={details.strMeal}
fill={true}
sizes="30vw"
style={{
objectFit: 'cover',
padding:'10px',}}
/>
</div>
<div className='flex flex-col gap-8 w-full lg:w-[90%]'>
<div className='w-full p-4 shadow bg-white rounded-lg'>
<h4 className=' font-catamaran font-medium text-zinc-500 text-lg md:text-xl lg:text-2xl border-b pb-2'>Ingredients:</h4>
<ul className='p-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 text-gray-700 text-base lg:text-lg font-jost'>
{ingredients.map((ingredient, index)=>(
<li key={index} >
{ingredient[1]} {ingredient[0]}
</li>
))}
</ul>
</div>
<div className='w-full p-4 shadow bg-white rounded-lg'>
<h4 className=' font-catamaran font-medium text-zinc-500 text-lg md:text-xl lg:text-2xl border-b pb-2'>Instructions:</h4>
<ul className='p-4 flex flex-col gap-2 text-gray-700 text-base lg:text-lg font-jost'>
{details.strInstructions.split('.') // Split instructions by newline
.map((step, index) => {
// Remove leading digits and tabs
const normalizedStep = step.trim()
if (normalizedStep.trim()!== '') { // Check if the step value is not empty and not only whitespace characters
return (
<li key={index}>
{normalizedStep}
</li>
);
} else {
return null; // Return null if the step value is empty or only whitespace characters
}
})}
</ul>
</div>
</div>
</div>
</div>
</div>

</>
)}

export default Details
  • By using a Dynamic Segment, we have passed params object inside the Details component.
  • Since the name of our folder is [id], then the property name we need to extract from the params object is also id. If params contains a property named id, its value will be assigned to the id variable.
  • The id variable will be used as a parameter to fetch details recipe from the `lookup.php?i=${id}` endpoint using the useSWR hook.
  • The endpoint will be the key of SWR, and the fetcher function accepts this key, returning data and isLoading based on the status of the request.
  • If data is still loading (isLoading is true), the DetailSkeleton component will be displayed to represent the process.
  • When the data becomes available, we use destructuring assignment to extract the first element from the data.meals array and assign it to a variable named details
const [details]=data.meals

When you log the details data in the console, you will see this is how the ingredients and measures properties look like:

  • To retrieve the ingredients and measures values, we need to loop through them (up to 20) using the for loop.
const [ingredients, setIngredients] = useState([])
...
useEffect(()=>{
if(!isLoading)
{
const ingredientsValue=[]
for(let i = 1; i<=20;i++)
{
const ingredientName=details[`strIngredient${i}`]
const ingredientMeasure = details[`strMeasure${i}`];
if(ingredientName)
{

ingredientsValue.push([ingredientName, ingredientMeasure]);
}

}
setIngredients(ingredientsValue)
console.log(details)
}
}, [isLoading])
  • Here, we dynamically extract the ingredient name from the details object. For example, when i is 1, it retrieves details[strIngredient1]. We also extract its measurement using the same approach.
  • If the ingredient exists, it pushes an array containing the ingredient name and its measurement into the ingredientsValue array.

This is what it’s called a 2-dimensional array, the inner array contains two elements. The first element is the ingredients name, and the second element is its measurement.

  • When the looping is done, we update the ingredients state with the ingredientsValue.

This is how we display the ingredients and its measures on the page:

<ul className='p-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 text-gray-700 text-base lg:text-lg font-jost'>
{ingredients.map((ingredient, index)=>(
<li key={index} >
{ingredient[1]} {ingredient[0]}
</li>
))}
</ul>

And this is how we display the instructions on the page:

<ul className='p-4 flex flex-col gap-2 text-gray-700 text-base lg:text-lg font-jost'>
{details.strInstructions.split('.') // Split instructions by newline
.map((step, index) => {
//removes spaces, tabs, and newline characters
const normalizedStep = step.trim()
if (normalizedStep.trim()!== '') { // Check if the step value is not empty
//and not only whitespace characters
return (
<li key={index}>
{normalizedStep}
</li>
);
} else {
return null; // Return null if the step value is empty
// or only whitespace characters
}
})}
</ul>

The instruction value doesn’t have the same format. Some recipes use numbers, while others use ‘Step-n,’ and some do not. So, instead of using numbers or bullets to split the instructions, it’s better to split them by newline.

recipes/page.js

Based on the route pattern we have created for this recipe details page, the URL will be recipes/recipeParameter, but when users manually change the URL to recipes, Next.js will displays the not-found page. So, to prevent this, we create a new page.js file for the recipes route to ensuring that users return to the root ("/") route.

"use client"
import {useRouter} from 'next/navigation'
const RecipePage = () => {
const router = useRouter()
router.push('/')
}
export default RecipePage
  • Inside the RecipePage, we used one of the useRouter hook methods, router.push('/'). This method is used to navigate to the root URL when the component is rendered.

Scroll-To-Top Button

components/scrollToTop.js

The scroll-to-top button allows users to quickly return to the top of the page.

'use client'
import React, { useState, useEffect } from 'react';
import {ArrowUp} from '../../../public/icons/svg';

const ScrollToTopButton = () => {
const [showButton, setShowButton] = useState(false);

useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);

const handleScroll = () => {
if (window.scrollY > 1000) {
setShowButton(true);
} else {
setShowButton(false);
}
};

const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};

return (
showButton && (<button title='Back to the top'
onClick={scrollToTop}
className="fixed bottom-4 right-2 sm:right-4 z-50 bg-white shadow rounded-lg"> <ArrowUp className="stroke-red-600 w-7 h-7 sm:w-9 sm:h-9"/>
</button>)

);
};

export default ScrollToTopButton;

However, there is a condition that determines when the button will appear.

  • First, we need to define a boolean state that determines whether to show the scroll-to-top button.
const [showButton, setShowButton] = useState(false);
  • Then we need to create a function to check the scroll position.
const handleScroll = () => {
if (window.scrollY > 1000) {
setShowButton(true);
} else {
setShowButton(false);
}
};
  • If the scroll position exceeds 1000 pixels, set showButton to true; otherwise, set it to false.

The scrollY property returns the pixels a document has scrolled from the upper left corner of the window.

  • When the showButton state value becomes true, the scroll-to-top button will be displayed. We add an event handler, scrollToTop to the button, allowing smooth scrolling back to the top of the page.
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};

return (
showButton && (<button title='Back to the top'
onClick={scrollToTop}
className="fixed bottom-4 right-2 sm:right-4 z-50 bg-white shadow rounded-lg"> <ArrowUp className="stroke-red-600 w-7 h-7 sm:w-9 sm:h-9"/>
</button>)

);
};
  • And last, we need to add an event listener to the window for the scroll event inside useEffect hook.
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
//Cleanup function
window.removeEventListener('scroll', handleScroll);
};
}, []);

The cleanup function is executed when the component unmounts. It removes the previously added event listener. This ensures that no memory leaks occur and that the event listener is properly cleaned up when the component is no longer needed.

The ScrollToTopButton component will not show up in Modal component, because we set the modal’s document.body.style.overflow to ’hidden’. We can only see the button on our main page, category recipes page, search page, and recipe details page.

Alright, we’re done with the ScrollToTopButton component, now we need to import it into our root layout, so we can use the scroll-to-top button when the scroll position exceeds 1000 pixels.

src/app/layout.js

...
import ScrollToTopButton from "./components/scrollToTop";

export default function RootLayout({ children, modal
}) {
return (
<html lang="...">
<body className="...">
...
{modal}
{children}
<ScrollToTopButton/>
...
</body>
</html>
);
}

Hero Section

components/hero.js

import {useState, useEffect} from "react";
import {Salad } from "../../../public/icons/svg";
import { HeroSkeleton } from "./skeleton";
const Hero= () => {
const [showSkeleton, setShowSkeleton] = useState(true);
useEffect(() => {
setTimeout(() => {
setShowSkeleton(false);
}, 75);
}, []);

return (
<>
{showSkeleton ?
(<HeroSkeleton/>):(
<div className="w-full px-6 sm:px-8 ">
<div className="relative w-full h-40 xs:h-48 xs-1:h-36 sm-2:h-40 md:h-44 md-2:h-48 lg:h-56 xl:h-60 bg-yellow-200/90 rounded-2xl shadow p-4 flex flex-col text-center xs-1:text-left xs-1:flex-row xs-1:justify-evenly items-center">
<div className='flex flex-col gap-1 xs-1:gap-2 xl:gap-4'>
<h1 className='font-jost text-xxs xs:text-sm xs-2:text-base sm-2:text-lg md:text-xl lg:text-2xl text-amber-800'>Let’s Embark on Your Culinary Journey</h1>
<span className='font-jost font-bold text-red-600 text-base xs:text-lg xs-1:text-xl sm-1:text-2xl sm-2:text-3xl md:text-4xl md:text-4xl-1 lg:text-5xl xl:text-5xl-1'>Discover Your Favorite <br/> Recipes Here</span>
</div>
<Salad className="absolute bottom-0 xs-1:relative w-20 h-20 xs:w-24 xs:h-24 sm-1:h-28 sm-1:w-28 sm-2:h-32 sm-2:w-32 md:h-40 md:w-40 lg:h-48 lg:w-48 xl:h-60 xl:w-60 sm:mb-2"/>
</div>
</div>
)} </>
)
}
export default Hero

This Hero component is a functional component that represents the hero section of our app. Since no data fetching occurs here, we display the HeroSkeleton for a short time.

Well, I think using the skeleton component even there is no data fetching process happened here can give a better user experience. Essentially, the other sections, categories and recipe collection sections, will require time for data fetching process, and in the process, we will display skeletons to represent it. This is to ensure the consistency of the UI when users load the main page.

After we’re done with the Hero component, we import the component into our main page.js

src/app/page.js

'use client'
import CategoriesList from "./components/categoriesList";
import Recipes from "./components/recipeCollection";
import Hero from "./components/hero";

export default function Home() {
return (
<main className="w-full h-full flex flex-col gap-6 md:gap-8">
<Hero/>
<CategoriesList/>
<Recipes/>
</main>
);
}

Finally, we have finished this long journey tutorial!

I know this tutorial is not perfect, and there are still many things to explore about Next.js. You might even get bored along the way, but I really hope this tutorial can be helpful to you.

You can check the full code of this RecipeFinder app in this GitHub repository: ucilasmana/RecipeFinder

Have a good day!

--

--

Uci Lasmana

Someone who enjoys every journey in developing ideas, passionate about solving problems without ignoring the beauty of the visuals.