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

Uci Lasmana
21 min readApr 28, 2024

--

Here, we will learn how to build a recipe finder web app using the Next.js framework. This app will feature a collection of recipes with infinite scrolling, a detailed recipe page, a search column to find recipes, and the ability to filter recipes by category.

From this tutorial, you will learn how to use an app router for routing, including dynamic routes, parallel routes, intercepting routes. You’ll also learn the difference in how to fetch data on the server and on the client. All of the recipe data will be sourced from TheMealDB API, which I selected because of its free access without limit.

Well, if you’re still a beginner in React, I recommend starting with this guide, React Hooks: A Companion Guide for React JS Beginner Projects. And if you are still new to APIs, consider reading this article, “A Guide to APIs (Collection of Questions and Answers for Beginners)”. These guides will help you gain basic knowledge.

Here we go!

RecipeFinder

This is a preview of the recipe finder app:

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

From the UI above we can get into details about what we need to build:

  1. Header section: This section contains the application name and a column where users can type any recipes they are looking for. The app then tries to match the keywords with the recipe titles from the API. It will either show the recipe or a list of similar recipes, or display a “no results found” message.
  2. Hero section: This section includes only a few lines of text and an icon.
  3. Categories section: Here, we display all the categories from the API. When users click on a category, the app will show all the recipes based on that category.
  4. Recipe Collection section: This section displays all the recipes from the API. However, showing all the data at once would be overwhelming, so we’ll set an initial batch of recipes and implement infinite scrolling. As users scroll down, more recipes will load dynamically.
  5. Recipe Details: When users click on one of the recipes, the app will display the details of that recipe.
  6. Infinite scrolling function: To achieve infinite scrolling, we’ll define how much data loads each time users reach the bottom of the page. While waiting for data to load, we’ll display a loading icon. Keep in mind that if there’s an excessive amount of data, it might take a while to return to the top, so we’ll also need a scroll-to-top button.
  7. Scroll-to-top button: This button will allow users to quickly return to the top of the page. We will determine the conditions when the button appears.

Alright, let’s build the app now!

Set Up

First, we need to set up the project, so we can focus on how to build the details of the app later.

Install Next.js

Next.js is a React framework that enables developers to build full-stack web applications. It’s designed to simplify the development process by providing built-in features such as server-side rendering, static site generation, and file-system based routing.

To create a Next.js project, we can using create-next-app, which sets up everything automatically.

npx create-next-app@latest

In the process of installation, you’ll see the following prompts:

Wait for the whole process. When it’s done, you can open the project folder and run this command:

npm run dev

Now, you can see the project live on localhost:3000.

If you encounter this problem, “Cannot find module ‘next/babel’”, It’s probably because you opened the project folder directly. However, if you’ve already opened the folder directly and are still encountering the error, consider trying these solutions.

Install third-party libraries/packages

  • Swiper.js

Swiper is a touch slider library that will be used to showcases the recipe categories through a slider. To install the Swiper.js in our Next project, run the following command:

npm i swiper
  • SWR

SWR is a third-party library React hooks, which will help us fetching data in a client component. Run this command to install the SWR library:

npm i swr
  • React Loading Skeleton

React Loading Skeleton is a package to make beautiful, animated loading skeletons. We will use the skeletons to display content while it is loading. This is how we install the package:

npm install react-loading-skeleton
  • React Intersection Observer

React Intersection Observer is a package that will help us create an infinite scrolling function. This package tells us when an element enters or leaves the viewport. To install the React Intersection Observer, run the following command:

npm install react-intersection-observer --save

Directory Structure

This is what the directory structure of this recipe finder app would look like:

Directory Structure

You can follow this directory structure if you want or just create your own versions. If you are wondering, about folders name using @, (.), [], don’t worry, I’ll explain it later.

Next.js uses a file-system based router where folders are used to define routes. Each folder represents a route segment that maps to a URL segment.

svg.js

Here, we’re going to use SVG icons which already transformed into React component using SVGR Playground. These icons will be used for food menu categories, search button, close modal, scroll to top button, back button, and food icon in hero section.

font.js

Since we are using Tailwind.CSS, we load the font via next/font/ using a CSS variable. Then we add the CSS variable to our tailwind.config.js. In Next.js we can just import the font that we like from Google Fonts by using next/font/google as a function.

  • font.js

Set CSS Rules

You can copy these CSS rules if you want, but if you have your own style, feel free to proceed with that.

error.js

The error.js file is an error UI that allows us to handle unexpected runtime errors in nested routes. With error.js, we isolate errors only to the affected segments while keeping the rest of the application functional. error.js also add functionality to attempt to recover from an error without a full page reload.

not-found.js

The not-found file is a not found UI which show up when users navigate to a URL that does not match any existing page in the application.

skeleton.js

Just like the loading.gif which represents the process when we load data, we need skeleton to represent our data while it’s still loading. You can learn how to create the skeletons here, React Loading Skeleton.

loading.gif

While waiting for the data to load, we need something like loading icon to represents the process. You can just download the loading.gif I provided below or just create your own loading icon for free here loading.io

next.config.mjs

The next.config.mjs is a configuration file for Next.js. Since we will use external images from TheMealDB API, we need to define a list of supported URL patterns with the remotePatterns property in next.config.mjs. This configuration is required to protect our application from malicious users, it ensures that only external images from the sources we’ve specified can be served from the Next.js Image Optimization API.

Well, we’re done with the setup, now we can focus on building the details.

Root Layout

In RootLayout , we import the font CSS variables we created before, and then use them in our RootLayout HTML. Here, we also define our application metadata.

src/app/layout.js

import { paytoneFont, catamaranFont, jostFont } from "../../public/fonts/font"
import "./globals.css";
export const metadata = {
title: 'RecipeFinder',
description: 'Find Your Favorite Recipes Here',
}

export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={`${paytoneFont} ${catamaranFont} ${jostFont}`}>
<div className="overflow-y-auto select-none h-full w-full flex flex-col gap-6 md:gap-8 pb-6 md:pb-8 overflow-x-hidden">
{children}
</div>
</body>
</html>
);}

The root layout is defined at the top level of the app directory and applies to all routes. This layout is required and must contain html and body tags, allowing you to modify the initial HTML returned from the server.

Header section

components/header.js

import Link from 'next/link'
import React from 'react'
import { SearchIcon } from '../../../public/icons/svg'
const Header = () => {
return (
<header className='flex bg-white p-4 shadow-sm justify-between items-center'>
<Link href="/" className="font-paytone text-base min-[380px]:text-xl sm:text-2xl text-red-600">RecipeFinder</Link>
<Link href="/search" className='flex items-center gap-2 py-1.5 justify-end border border-gray-100/75 hover:border-gray-300 bg-gray-50 px-2 min-[380px]:px-5 rounded-full'> <span className='font-jost text-xxs min-[380px]:text-sm text-gray-500'>Find Recipe </span>
<SearchIcon className="h-3 w-3 min-[380px]:h-5 min-[380px]:w-5 stroke-red-600"/>
</Link>
</header>
)
}
export default Header

This Header component is a functional component that represents the header section of our app. Here, we have two Link components:

  • The first Link component purpose is to navigate users back to the root (“/”) route after they visit another page.
  • The second Link component purpose is to navigate users to the “/search” route, where users can search the recipes they are looking for.

<Link> is the primary and recommended way to navigate between routes in Next.js. It is a built-in component that extends the HTML <a> tag, so we can pass a href prop to the component and then specifies the URL of the page the link goes to. We can use <Link> by importing it from next/link.

<Link> provide prefetching which is a way to preload a route in the background before the user visits it.

Considering the Header component is the header of our app, we’re going import it into our root layout, so even if the pages change, the Header will still stay.

src/app/layout.js

...
import Header from "./components/header";
...

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

Search Section

When users click the Link component for Find Recipe, a modal will appear with a search column on top of a page where users clicked the Link. But when we reload the app, the search column will be displayed on another page which is search page, not the search modal. This scenario is the result from the combination of parallel routes and intercepting routes.

Parallel Routes allows us to simultaneously or conditionally render one or more pages within the same layout. Intercepting routes allows us to load a route from another part of your application within the current layout.

Here are a few advantages of using the combination of parallel routes and intercepting routes for a modal:

1. Users can make the modal content shareable through a URL

2. The modal content remains preserved. The content does not disappear when users refresh the page.

3. If users navigate away from the modal and then return, the modal will reopen with the same content. Usually, users need to perform an action to open a modal.

This is how we will implement the parallel routes and intercepting routes:

Parallel Routes

Remember this @ in modal folder from our directory structure? Well, this is called slots. Parallel routes are created using named slots. Slots are defined with the @folder convention. Slots are passed as props to the shared parent layout. The layout component can render slots in parallel alongside the children prop.

src/app/layout.js

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

Then, inside the @modal slot, add default.js file that returns null. This ensures that the modal is not rendered when it's not active.

@modal/default.js

export default function Default() {
return null;
}

Slots are not route segments and do not affect the URL structure. For example, for /@modal/search, the URL will be /search since @modal is a slot.

Intercepting Routes

To define intercepting routes, we use the (..) convention, which is similar to relative path convention ../ but for segments.

The (..) convention is based on route segments, not the file-system. This is how we use the (..) convention to define intercepting routes:

(.) to match segments on the same level,

(..) to match segments one level above

(..)(..) to match segments two levels above

(...) to match segments from the root app directory

As shown in the directory structure above, inside @modal slot, we intercept the /category and /search route. We use (.) to match segments on the same level.

You might wonder why we don’t use (..) to match segments one level above, considering that the categories and search route are inside the @modal. Well, the @modal is a slot and not a segment. This means that the categories and search route inside the @modal are on the same level as the categories and search route outside, even though they are one file-system level higher.

Search Modal

@modal/(.)search/page.js

import Modal from "@/app/components/modal"
import Search from "@/app/components/search"

const SearchModal = () => {
return (
<>
<Modal>
<Search/>
</Modal>
</>
)}

export default SearchModal

Inside the /(.)search/page.js file, we import <Modal> component and its children, <Search> component.

Modal Component

components/modal.js

'use client'
import React, { useEffect } from 'react'
import { CloseIcon } from '../../../public/icons/svg';
import { useRouter } from 'next/navigation'

const Modal = ({children}) => {
const router=useRouter()
const handleClose = () => {
document.body.style.overflow='auto'
router.back()
}
useEffect(() => {
document.body.style.overflow ='hidden'
},[]);

return (
<div className='w-full h-full fixed top-0 left-0 bg-black/30 flex flex-col z-10'>
<CloseIcon className="z-20 absolute rounded-lg bg-white shadow fill-red-500 hover:fill-red-700 top-2 p-0.5 right-2 h-7 w-7 cursor-pointer" onClick={handleClose}/>
<div className='h-[93%] w-[93%] absolute bottom-0 left-1/2 -translate-x-1/2 font-jost'>
<div className='relative w-full h-full bg-gray-50 py-8 px-3 sm:px-5 rounded-t-3xl shadow-md'>
<div className='overflow-y-auto w-full h-full'>
{children}
</div>
</div>
</div>
</div>
)
}

export default Modal
  • Inside the <Modal> component, we declare "use client" directive at the top of a file, above our imports. This is because, by default, all components in the App Router are Server Components where React Hooks are not available. By defining the "use client" directive in modal.js, you can tell React to enter the client boundary where these Hooks are available.

"use client" is used to declare a boundary between a Server and Client Component modules. This means that by defining a "use client" in a file, all other modules imported into it, including child components, are considered part of the client bundle.

These are conditions that we need to consider when we need our application rendered on the server or on the client:

When to use Server and Client Components?
  • Here we import the useEffect hook from react and the useRouter hook from 'next/navigation'.
  • When the modal appears on top of the page, the useEffect runs once and sets the overflow style of the document.body to hidden (disabling scrolling). The purpose of this effect is to enhance the user experience when scrolling down inside the modal, without being bothered by the background scrollbar.
  • The router variable is created using the useRouter hook. This hook provides access to the Next.js router, allowing us to navigate between pages in our application.
  • When users click the CloseIcon, the handleClose function will be called. It then sets the overflow style of the document.body to 'auto' (allowing scrolling) and navigates back using router.back().

router.back() is a useRouter method used to navigate back to the previous route in the browser’s history stack.

  • When we call router.back() to navigate away from a page that shouldn't render the @modal slot anymore, we use a catch-all route that returns null.

app/@modal/[…catchAll]/page.js

export default function CatchAll() {
return null
}

We use a catch-all route in our @modal slot to close the modal because of the behavior described in Active state and navigation. Since client-side navigations to a route that no longer match the slot will remain visible, we need to match the slot to a route that returns null to close the modal.

  • The Modal component receives a single prop called children. This prop represents the Search component that will be displayed inside the Modal component.

Search Component

components/search.js

'use client'
import {useState } from "react"
import List from "./recipeList"
import useSWR from "swr"
import { fetcher } from "../lib/fetcher"
import { SearchIcon } from "../../../public/icons/svg"

const Search = () => {

const [recipe, setRecipe] =useState('')
const { data} = useSWR(`search.php?s=${recipe}`, fetcher)

return (
<div className="pt-4 w-full px-6 sm:px-8 md:px-10 lg:px-16">
<div className="w-full font-jost flex flex-col gap-10 items-center">
<h3 className='text-lg sm:text-xl md:text-2xl font-bold text-red-700 bg-white rounded-xl shadow py-2 px-3 w-fit text-center'>Find Your Favorite Recipes</h3>
<div className='bg-white flex items-center gap-2 p-4 justify-between w-full rounded-xl shadow '>
<input type="text" value={recipe} onChange={e=>setRecipe(e.target.value)} placeholder="Search" className="w-full placeholder-gray-500 text-sm min-[360px]:text-base sm:text-lg md:text-xl text-gray-700 outline-none" required/>
<SearchIcon className="h-3 w-3 min-[380px]:h-5 min-[380px]:w-5 stroke-red-600"/>
</div>
</div>
<div className="mt-10">
{data? data.meals? <List recipeList={data.meals} totalRecipes={data.meals.length}/>
: <div className="mt-20 gap-2 flex flex-col w-full items-center justify-center text-base md:text-lg lg:text-xl text-zinc-500 font-catamaran"><h4 className="bg-white px-3 py-1 shadow rounded-lg">No results found</h4><span className="shadow rounded-lg bg-white px-3 py-1">Try a different keyword</span></div>
: null}
</div>
</div>
)}

export default Search
  • Inside our Search component we also declare ‘use client’ because we use hooks such as useState and useSWR. Here, we will fetch recipes data from TheMealDB API.
...
import { fetcher } from "../lib/fetcher"
...

const Search = () => {

const [recipe, setRecipe] =useState('')
const {data} = useSWR(`search.php?s=${recipe}`, fetcher)

return (
...
<div className='bg-white flex items-center gap-2 p-4 justify-between w-full rounded-xl shadow '>
<input type="text" value={recipe} onChange={e=>setRecipe(e.target.value)} placeholder="Search" className="w-full placeholder-gray-500 text-sm min-[360px]:text-base sm:text-lg md:text-xl text-gray-700 outline-none" required/>
<SearchIcon className="h-3 w-3 min-[380px]:h-5 min-[380px]:w-5 stroke-red-600"/>
</div>
...
)}
  • In the search input field, we use the onChange event handler to updates the recipe state with the new value entered by the user.
  • The recipe state will be used to fetch data from the search.php?s=${recipe} endpoint.
const {data} = useSWR(`search.php?s=${recipe}`, fetcher)
  • The endpoint will be the key of SWR, and the fetcher is an async function that accepts the key and returns the data.

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
}
}
  • The fetched data will be pass to the List Component.
...
import List from "./recipeList"
...
const Search = () => {
...
return (
...
<div className="mt-10">
{data? data.meals? <List recipeList={data.meals} totalRecipes={data.meals.length}/>
: <div className="mt-20 gap-2 flex flex-col w-full items-center justify-center text-base md:text-lg lg:text-xl text-zinc-500 font-catamaran"><h4 className="bg-white px-3 py-1 shadow rounded-lg">No results found</h4><span className="shadow rounded-lg bg-white px-3 py-1">Try a different keyword</span></div>
: null}
</div>
...
)}

export default Search
  • If the data is successfully fetched, it will display the list of recipes using the <List> component. However, if not, it will show alternative messages: ‘No results found’ and ‘Try a different keyword’.

We will talk about the List component in the Part 2 of this tutorial. For Part 1, our focus is on setting up the app, understanding how to combine parallel routes and intercepting routes, and learning how to fetch data using useSWR.

Alright we’ve done with the search modal, let’s talk about the search page.

Search Page

The search page is the main page that shows up when we reload the app while the search modal is open.

search/page.js

import BackButton from "../components/backButton"
import Search from "../components/search"

const SearchPage = () => {
return (
<>
<BackButton/>
<Search/>
</>
)}

export default SearchPage
  • Inside the page.js file we import the <BackButton/>, and the <Search/> component which we import inside the <Modal> component before.

components/backButton.js

'use client'
import React from 'react'
import { ArrowLeft } from '../../../public/icons/svg'
import { useRouter } from 'next/navigation'

const BackButton = () => {
const router=useRouter()
const handleBack = () => router.back()

return (
<div onClick={handleBack} className="ml-4 sm:ml-6 md:ml-8 cursor-pointer hover:shadow bg-white flex gap-1 justify-center items-center rounded-lg hover:border-white border border-gray-100 py-0.5 px-2 w-fit" href='/'>
<ArrowLeft className="h-5 w-5 cursor-pointer stroke-gray-700"/>
<span className='font-jost font-semibold text-xs sm:text-sm md:text-base text-gray-700 '>Back</span>
</div>
)
}

export default BackButton
  • In the BackButton component, we used ‘use client’ because we need to use useRouter hook.
  • Here, we also defined router variable using the useRouter hook.
  • When users click the BackButton component, the handleBack function will be called. It will navigates back using router.back().

Categories section

In this section we display all the recipe categories data from TheMealDB API. Here we use the Swiper component to create a slider of category items. Every category will be wraps by the SwiperSlide component.

components/categoriesList.js

import { Beef, Chicken, Dessert, Lamb, Miscellaneous, Pasta, Pork, Seafood, Side, Starter, Vegan, Vegetarian, Breakfast, Goat } from '../../../public/icons/svg';
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/css';
import useSWR from 'swr'
import {fetcher } from "../lib/fetcher";
import {CategoriesSkeleton, HeadingSkeleton} from '../components/skeleton';
import Link from 'next/link';

const Icons = {
Beef: Beef,
Chicken: Chicken,
Dessert: Dessert,
Lamb: Lamb,
Miscellaneous: Miscellaneous,
Pasta: Pasta,
Pork: Pork,
Seafood: Seafood,
Side: Side,
Starter: Starter,
Vegan: Vegan,
Vegetarian: Vegetarian,
Breakfast: Breakfast,
Goat: Goat
};

const CategoriesList = () => {
const { data, isLoading} = useSWR(`categories.php`, fetcher)
if (isLoading) {
return (
<>
<HeadingSkeleton/>
<CategoriesSkeleton/>
</>
)
}

const categories=data.categories

return (
<div className='z-0 mt-3 flex flex-col gap-6'>
<span className='ml-6 sm:ml-8 font-jost font-semibold text-gray-800 text-base md:text-lg'>Categories</span>
<Swiper className='font-catamaran text-center w-full'
spaceBetween={10}
slidesPerView={3}
breakpoints={{
300:{
slidesPerView: 4,
spaceBetween:15
},
540:{
slidesPerView: 5,
spaceBetween:20
},
// when window width is >= 640px
700: {
slidesPerView: 6,
spaceBetween:30
},
980: {
slidesPerView: 8,
spaceBetween:30
},
}}
>
{categories.map((category) => {
const Icon = Icons[category.strCategory]; // select the icon component based on the category name
return (
<SwiperSlide className="first:ml-6 first:sm:ml-8 mb-4 w-full cursor-pointer font-medium" key={category.idCategory}>
<Link href={`/categories/${category.strCategory}`} className='flex flex-col justify-center items-center gap-2 bg-white py-2 rounded-lg shadow-md'>
<Icon className="h-6 w-6 xs-1:h-8 md:h-10 lg:h-12 xs-1:w-8 md:w-10 lg:w-12"/>
<span className='text-xxs sm:text-xs md:text-sm text-zinc-700'>{isLoading ? "loading" : category.strCategory} </span>
</Link>
</SwiperSlide>
);
})}
<SwiperSlide/>
</Swiper>
</div>
)}

export default CategoriesList
  • To get the recipe categories list, we need to fetch data from the categories.php endpoint using the useSWR hook.
const { data, isLoading} = useSWR(`categories.php`, fetcher)
if (isLoading) {
return (
<>
<HeadingSkeleton/>
<CategoriesSkeleton/>
</>
)
}

const categories=data.categories
  • The endpoint serve as the key of SWR, and the fetcher function accepts the key and returns data and isLoading based on the status of the request.
  • If data is still loading (isLoading is true), the HeadingSkeleton and CategoriesSkeleton components will be displayed to represent the process.
  • When the data becomes available, we assign data.categories to the categories variable.
  • The map function iterates over each category in the categories array, selecting the appropriate Icon based on the category name, then returns elements for each one category.
{categories.map((category) => {
const Icon = Icons[category.strCategory]; // select the icon component based on the category name
return (
<SwiperSlide className="first:ml-6 first:sm:ml-8 mb-4 w-full cursor-pointer font-medium" key={category.idCategory}>
<Link href={`/categories/${category.strCategory}`} className='flex flex-col justify-center items-center gap-2 bg-white py-2 rounded-lg shadow-md'>
<Icon className="h-6 w-6 xs-1:h-8 md:h-10 lg:h-12 xs-1:w-8 md:w-10 lg:w-12"/>
<span className='text-xxs sm:text-xs md:text-sm text-zinc-700'>{category.strCategory} </span>
</Link>
</SwiperSlide>
);
})}
  • After we’re done with the CategoriesList component, we import the component into our main page.js

src/app/page.js

'use client'
import CategoriesList from "./components/categoriesList";

export default function Home() {
return (
<main className="w-full h-full flex flex-col gap-6 md:gap-8">
<CategoriesList/>
</main>
);
}
  • As shown above, there is a ‘use client’ directive in the top of our main page.js. The reason why we use this directive is because all child components here will need to use ‘use client’ directive. You’ll see why other components require this directive later in this tutorial.

Just like the search section, we also used the combination of parallel routes and intercepting routes for the categories section.

When users click on one of the categories, the app displays all recipes based on that category inside the <Modal> component. However, when we reload the app, the category recipes will be shown on another page which is category recipes page, not the category recipes modal.

However, there is a slight difference between the search section and the categories section. Inside the categories folder we have this [category] folder, which is what is called a Dynamic Segment.

Dynamic Segment can be created by wrapping a folder’s name in square brackets: [folderName]. The folder name serves as the parameter for the route.

The categories route would look like this, app/categories/[category]/page.js but the URL would be categories/theCategoryParameter , let’s say the parameter is ‘Beef’, then the URL would be categories/Beef.

Category Recipes Modal

@modal/(.)categories/[category]/page.js

import CategoryRecipesList from '@/app/components/categoryRecipesList'
import Modal from '@/app/components/modal'

const CategoryRecipesModal = ({params}) => {
const {category} = params
return (
<>
<Modal>
<CategoryRecipesList category={category}/>
</Modal>
</>
)}

export default CategoryRecipesModal
  • By using a Dynamic Segment, we have passed params object inside theCategoryRecipesModal.
  • Since the name of our folder is [category], then the property name we need to extract from the params object is also category.
  • If params contains a property named category, its value will be assigned to the category variable.
  • The category variable will be passed as a prop to CategoryRecipesList component.

CategoryRecipesList Component

components/categoryRecipesList.js

import List from '@/app/components/recipeList'
import { fetcher } from '@/app/lib/fetcher'

const CategoryRecipesList = async({category}) => {
let recipeList=[]
let totalRecipes=0

const data = await fetcher(`filter.php?c=${category}`)

if(data){
recipeList=data.meals
totalRecipes=recipeList.length
}


return (
<div className='flex flex-col gap-8 sm:gap-10 items-center p-4 sm:p-6'>
<h3 className='text-lg sm:text-xl md:text-2xl font-bold text-red-700 bg-white rounded-xl shadow py-2 px-3 text-center'>{category} Category </h3>
{totalRecipes>0 &&
<List recipeList={recipeList} totalRecipes={totalRecipes}/>}

</div>
)
}

export default CategoryRecipesList

Before we discuss the code snippet above, let’s talk about Server Components first.

By default, all components in the App Router are Server Components. The reason, we don’t use the ‘use client’ directive is that we only need to fetch data without any interactivity that requires immediate feedback to the user and update the UI. Also, we don’t use browser APIs, like geolocation or localStorage here.

Server Components vs Client Components

The client refers to the browser on a user’s device that sends a request to a server for our application code. It then turns the response it receives from the server into an interface the user can interact with.

The server refers to the computer in a data center that stores our application code, receives requests from a client, does some computation, and sends back an appropriate response.

Rendering and data fetching on the server can reduce the amount of code sent to the client, which can improve our application’s performance. But it’s all depending on our requirements, do we need to make our UI interactive? then we need to update the DOM on the client.

  • Inside our CategoryRecipesList.js file, we defined a Server Component named CategoryRecipesList by using the async keyword.
const CategoryRecipesList = async({category}) => {
...
const data = await fetcher(`filter.php?c=${category}`)
...
  • The purpose of using the async function is because we need to use the await keyword, so we can ensure that our component doesn’t proceed until the data is available.

Without async/await, our component might continue executing before the data is fully fetched.

In Server Components, we can fetch data with async/await function directly, because they are executed on the server-side (SSR). This allows us to fetch data from an API or database and render the component with the fetched data on the server. SSR ensures that the initial HTML sent to the client already contains the data, improving performance and SEO.

While in Client Components, we cannot fetch data using async/await functions directly in the component, because they are executed on the client-side (in the browser). The browser doesn’t have direct access to the server-side API or database.

The component is rendered on the client-side, which means it needs to be hydrated with data that is already available on the client-side. So, to fetch data in client components we can use useEffect() hook to fetch data when the component mounts or updates, or use third party library, like SWR.

  • The category prop, that CategoryRecipesList component receives is used as a parameter to fetch the recipe list data.
  • We fetch data from the filter.php?c=${category} endpoint using the fetcher function.
  • When the data is available, we assign data.meals to the recipeList variable.
  • We also 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 List component. Then the List component will be rendered.

Category Recipes Page

categories/[category]/page.js

import CategoryRecipesList from '@/app/components/categoryRecipesList'
import BackButton from '@/app/components/backButton'

const CategoryRecipesPage = ({params}) => {
const {category} = params
return (
<div className=' w-full h-full px-4 sm:px-6 md:px-8'>
<BackButton/>
<div className='bg-zinc-200/40 p-5 sm:p-8 shadow-inner rounded-3xl sm:m-6 m-2 my-4 border border-gray-50'>
<CategoryRecipesList category={category}/>
</div>
</div>
)}

export default CategoryRecipesPage
  • Inside the CategoryRecipesPage component, we also pass the category variable to CategoryRecipesList component.
  • Based on the route pattern we have created for this category recipes page, the URL will be categories/categoryParameter, but when users manually change the URL to categories/, Next.js will look for a matching route. If it doesn’t find one, it displays the not-found page. So, to prevent this, we create a new page.js file for the categories route to ensuring that users return to the root ("/") route.

categories/page.js

"use client"
import {useRouter} from 'next/navigation'

const CategoriesPage = () => {
const router = useRouter()
router.push('/')
}

export default CategoriesPage
  • Inside the CategoriesPage, 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.

Here we are reaching the end of the Part 1 of this tutorial. For the part 2 we will focus on building the recipe collection section, the List component, an infinite scrolling function, a scroll-to-top button, the recipe details page, and the Hero Section.

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

--

--

Uci Lasmana

Sharing knowledge not only helps me grow as a developer but also contributes to the growth of fellow developers. Let’s grow together!