Get Cooking with Next.js: A Step-by-Step Guide to Crafting a Recipe Finder App (Part 1)
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:
- 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.
- Hero section: This section includes only a few lines of text and an icon.
- 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.
- 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.
- Recipe Details: When users click on one of the recipes, the app will display the details of that recipe.
- 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.
- 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:
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 containhtml
andbody
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 ahref
prop to the component and then specifies the URL of the page the link goes to. We can use<Link>
by importing it fromnext/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 rootapp
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 thecategories
andsearch
route are inside the@modal
. Well, the@modal
is a slot and not a segment. This means that thecategories
andsearch
route inside the@modal
are on the same level as thecategories
andsearch
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 inmodal.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:
- Here we import the
useEffect
hook fromreact
and theuseRouter
hook from'next/navigation'
. - When the modal appears on top of the page, the
useEffect
runs once and sets theoverflow
style of thedocument.body
tohidden
(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 theuseRouter
hook. This hook provides access to the Next.js router, allowing us to navigate between pages in our application. - When users click the
CloseIcon
, thehandleClose
function will be called. It then sets theoverflow
style of thedocument.body
to'auto'
(allowing scrolling) and navigates back usingrouter.back()
.
router.back()
is auseRouter
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 returnsnull
.
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 returnsnull
to close the modal.
- The
Modal
component receives a single prop calledchildren
. This prop represents theSearch
component that will be displayed inside theModal
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 asuseState
anduseSWR
. 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 therecipe
state with the new value entered by the user. - The
recipe
state will be used to fetch data from thesearch.php?s=${recipe}
endpoint.
const {data} = useSWR(`search.php?s=${recipe}`, fetcher)
- The endpoint will be the
key
of SWR, and thefetcher
is an async function that accepts thekey
and returns thedata
.
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 usinguseSWR
.
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 useuseRouter
hook. - Here, we also defined
router
variable using theuseRouter
hook. - When users click the
BackButton
component, thehandleBack
function will be called. It will navigates back usingrouter.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 theuseSWR
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 thefetcher
function accepts thekey
and returnsdata
andisLoading
based on the status of the request. - If
data
is still loading (isLoading
is true), theHeadingSkeleton
andCategoriesSkeleton
components will be displayed to represent the process. - When the
data
becomes available, we assigndata.categories
to thecategories
variable. - The
map
function iterates over each category in thecategories
array, selecting the appropriateIcon
based on thecategory
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 mainpage.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 mainpage.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 becategories/theCategoryParameter
, let’s say the parameter is ‘Beef’, then the URL would becategories/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 theparams
object is alsocategory
. - If
params
contains a property namedcategory
, its value will be assigned to thecategory
variable. - The
category
variable will be passed as a prop toCategoryRecipesList
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 namedCategoryRecipesList
by using theasync
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 theawait
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
withasync
/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, thatCategoryRecipesList
component receives is used as a parameter to fetch the recipe list data. - We fetch data from the
filter.php?c=${category}
endpoint using thefetcher
function. - When the data is available, we assign
data.meals
to therecipeList
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 therecipeList
and thetotalRecipes
as props toList
component. Then theList
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 thecategory
variable toCategoryRecipesList
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 tocategories/
, Next.js will look for a matching route. If it doesn’t find one, it displays thenot-found
page. So, to prevent this, we create a newpage.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 theuseRouter
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)