Creating Your Movie Bookmark Application using Next js, Redux Toolkit, Firebase, and TypeScript
Eboreime ThankGod
Posted on August 31, 2023
In this tutorial, we'll demonstrate how to create a movie bookmark application using the Next.js framework (version 13.4.13). We'll also integrate the Redux Toolkit for efficient state management and data fetching, Firebase for storing movie data, and TypeScript for safe type parsing.
You can find the completed code on GitHub, along with a live demo.
Below is a brief video clip demonstrating what we will be building.
Jump Ahead:
- Introduction to Next js
- Prerequisite
- Setting up a new Nextjs Project
- Installing packages
- Project setup
- Setting up our redux store
- Setting up our redux slice
- Setting up our redux thunk
- Making redux accessible in our application
- Creating our components
- Creating our movies page
- Creating our bookmarks page
- Running our application
- Conclusion
Introducing Next js
Next.js is a frontend React framework that offers support for various rendering approaches, including:
-
Client-Side Component Rendering: In Next.js, client components can be utilized by including the
"use client"
directive at the beginning of the component file. These client components can be rendered during the request phase, eliminating the necessity of being retrieved from the server. - Server-Side Component Rendering: By default, Next.js uses server-side rendering, a technique that involves rendering and optional caching of UI components on the server. The cached results can be utilized multiple times without initiating new requests. This approach effectively enhances the optimization of your web application.
With the introduction of the 'App' directory in the early release of Next.js 13, the framework facilitates seamless routing and nested layout structures between pages. Additionally, Next.js provides default Search Engine Optimization (SEO) features for your web applications.
Prerequisites
For this tutorial, you should have the following:
- Working knowledge of React and Typescript
- Your computer should have Node.js v16 or a newer version installed.
- Make sure you have Visual Studio Code installed or any other preferred editor.
Setting up a new Next js project
Creating a new Next.js application is made seamless with the utilization of the create-next-app
command. This command takes care of setting up all the necessary components and configurations, simplifying the initial setup process. To begin, open your terminal and enter the following:
npx create-next-app@latest
You will be prompted to fill in the following inputs:
-
What is your project named? movie-bookamrk
: let's name the project as movie-bookmark. -
Would you like to use TypeScript? No / Yes
: TypeScript is required for this project, so select Yes. -
Would you like to use ESLint? No / Yes
: ESLint is necessary for this project, so choose Yes. -
Would you like to use Tailwind CSS? No / Yes
: In this tutorial, we will utilize Tailwind CSS for styling, so choose Yes. -
Would you like to use src/ directory? No / Yes
: For the purpose of this project, we'll go with Yes as we'll be using the src directory. -
Would you like to use App Router? (recommended) No / Yes
: It's recommended to choose either No or Yes. In this case, choose No. -
Would you like to customize the default import alias? No / Yes
: Select Yes for default import alias. -
What import alias would you like configured? @/*
: press Enter to use the@/*
alias.
Once you've completed these steps, in the terminal, navigate to the movie-bookmark directory and install the dependencies, then run the server:
# cd into directory
cd movie-bookmark
# install packages
npm install
# start server
npm run dev
Installing packages
In this tutorial, we will proceed with the installation of the necessary packages that are essential for our application. The following packages will be utilized, each accompanied by its respective installation command:
# framer for animations
npm i framer-motion
# firebase for database management
npm i firebase
# react-redux for state management
npm i react-redux
# for notification popups
npm i react-hot-toast
# for creating redux store, slice, and thunks
npm i @reduxjs/toolkit
# for icons
npm i @mui/icons-material
# for effective styling of the material ui icons
npm i @emotion/styled @emotion/react
Project setup
Setting up the firebase config
Once you have successfully installed all the required packages, proceed to access your Firebase console. Create a fresh Firebase project, and make sure to copy the configuration SDK provided by Firebase. Next, within the root directory of your project, create a file named firebase.config.ts
. Paste the previously copied Firebase configuration into this file. Below, you will find the firebase.config.ts
configuration file. Be sure to substitute the placeholder values with your actual configuration details.
//firebase.config.ts
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getAuth } from "firebase/auth";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_KEY,
authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_ID,
appId: process.env.NEXT_PUBLIC_APP_ID,
measurementId: process.env.NEXT_PUBLIC_MEASUREMENT_ID,
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
export const auth = getAuth(app);
export default app;
The provided code demonstrates the setup of the Firebase app using the configuration stored in firebaseConfig
. The initializeApp
function produces an app instance, which serves as the gateway to Firebase services. To create instances of the Firestore and authentication services, use the getFirestore
and getAuth
functions. These functions configure the initially initialized Firebase app (app) with the respective instances.
Understanding the folder strucutre
├── movie-bookmark
├── context
├── redux.provider.tsx
├── data
├── movie.ts
├── node_modules
├── public
├── redux
├── features
├── bookmarkSlice.ts
├── bookmarkThunk.ts
├── hooks.ts
├── store.ts
├── src/app
├── bookmarks
├── page.tsx
├── components
├── AnimationWrapper.tsx
├── Header.tsx
├── MovieCard.tsx
├── favicon.ico
├── global.css
├── layout.tsx
├── page.tsx
├── types
├── movie-types.ts
├── firebase.config.ts
In the folder structure above, we observe a redux/
folder that houses the features/
folder. Within the features/
folder, we find the bookmarkSlice.ts
and bookmarkThunk.ts
, along with the hooks.ts
and store.ts
files. These files will be elaborated upon in the subsequent sections of this tutorial.
Moving on, the src/app
directory takes a pivotal role in Next.js as it's where our routes are established. Notably, the layout.ts
file serves as the foundational layout for our application, while the page.tsx
file represents the primary page that a user sees when accessing localhost:3000
.
In Next.js, each subfolder within the src/app
directory signifies a distinct route segment. It's imperative that each of these subfolders contains an exported page.tsx
file. Examining our current folder structure, we can identify the bookmarks/
folder housing a page.tsx
file, which corresponds to the route accessible at localhost:3000/bookmarks
.
As we delve further, the components/
folder is used for storing reusable components (for this project: AnimationWrapper.tsx
, Header.tsx
,and MovieCard.tsx
). It's important to note that this folder doesn't serve as a route since it lacks a page.tsx
file.
Setting up our redux store
movie-bookmark/redux/store.ts
Following the previously outlined folder structure, create a redux/
folder, inside the folder create a store.ts
file then add the following code:
//store.ts
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({
reducer: {},
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false,
}),
devTools: process.env.NODE_ENV !== "production",
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
In the above code snippet, we import configureStore
to create a new Redux configuration called store
. This store
will hold our imported reducers
. We adjust the middleware using getDefaultMiddleware
, turning off serializableCheck
for non-serializable state values. The devTool
option enables the Redux dev tool only in development mode. RootState
captures the complete store state using store.getState()
's return type. AppDispatch
specifies the dispatch function's type for accurate action dispatching and minimal errors.
movie-bookmark/redux/hooks.ts
Following the previously outlined folder structure, create a new file named hooks.ts
inside the redux/
folder and add the following code:
//hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Frome the code above, instead of repeatedly importing the RootState
and AppDispatch
types into distinct components, it is preferable to build typed versions of the useDispatch
and useSelector
hooks. This approach improves the use of these hooks throughout your entire program.
Setting up our redux slice
movie-bookmark/redux/features/bookmarkSlice.ts
Let's start by setting up our bookmarkSlice
. To begin, create a types/
declaration folder at the root directory, specifically for our MovieCard
component type, bookmark
and user
state types. These types will later be imported into our bookmarkSlice.ts
module.
Create a new file named movie-type.ts
inside the types/
folder, and insert the code provided below:
//movie-type.ts
export interface UserProps {
accessToken: string | any;
auth: any;
displayName: string;
email: string | any;
emailVerified: boolean;
isAnonymous: boolean;
metadata: any;
phoneNumber: any;
photoURL: string | any;
proactiveRefresh: any;
providerData: any;
providerId: string | any;
reloadListener: any | null;
reloadUserInfo: any;
tenantId: any | null;
uid: string;
}
export interface MovieCardProps {
title: string;
movieId: number;
poster_path: string;
release_date: string;
backdrop_path: string;
id?: number;
movieRating?: number;
vote_average?: number;
user: UserProps
}
export interface MovieThunkProp {
background?: string;
date?: string;
poster_path: string;
id: number;
title: string;
}
In the provided code snippet, we are introducing three distinct types: UserProps
, MovieCardProps
, and MovieThunkProps
. Each of these types has a specific purpose within our technical implementation, and their explanations are provided below.
The UserProps
type pertains to the anticipated payload originating from Firebase authentication. This type describes the structure of the data we expect to receive from this process.
Furthermore, the MovieCardProps
type is dedicated to our MovieCard
component. This component will be thoroughly examined in the subsequent segment of this article. The MovieCardProps
type outlines the expected structure of properties that can be utilized within the MovieCard component.
Lastly, the MovieThunkProps
types come into play in the context of the bookmark state as well as interactions with the Firebase database. These types encompass the necessary specifications for handling state related to bookmarking and interfacing with the Firebase database.
Once you have declared the required types, proceed to create a new file named bookmarkSlice.ts
within the redux/features/
directory. Insert the following code:
//bookmarkSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { MovieThunkProp, UserProps } from "../../types/movie.type";
interface BookMarkState {
bookmarked: MovieThunkProp[];
error: string | null;
bookmarkError: string | null;
user: UserProps | null;
}
const initialState: BookMarkState = {
bookmarked: [],
error: null,
bookmarkError: null,
user: null,
};
const bookmarkSlice = createSlice({
name: "bookmark",
initialState,
reducers: {
addMovieToBookmarked(state, action: PayloadAction<MovieThunkProp>) {
state.bookmarked.unshift(action.payload);
},
removeFromBookmarked(state, action: PayloadAction<number>) {
state.bookmarked = state.bookmarked.filter(
(movie) => movie.id !== action.payload
);
},
addBookmarkFail(state, action: PayloadAction<string>) {
state.error = action.payload;
},
getBookmarkError(state, action: PayloadAction<string>) {
state.bookmarkError = action.payload;
},
setUser(state, action) {
state.user = action.payload;
},
updateBookmarks(state, action: PayloadAction<MovieThunkProp[]>) {
state.bookmarked = action.payload;
},
},
});
export const {
addMovieToBookmarked,
removeFromBookmarked,
addBookmarkFail,
getBookmarkError,
setUser,
updateBookmarks,
} = bookmarkSlice.actions;
export default bookmarkSlice.reducer;
From the code above, we import the createSlice and PayloadAction from redux toolkit, for creating our bookmark slice. We also import our MovieThunKProp
and UserProps
we created previously.
We then define our state, which holds the following:
-
bookmarked
: holds movie objects that are bookmarked. -
error
: A string or null to hold any potential errors related to bookmark actions. -
bookmarkError
: A string or null to hold errors related to bookmark operations. -
user
: A UserProps object or null to store user information.
After defining our state, we then create our slice using the createSlice
and name it bookmark
, which holds the following reducer functions:
-
addMovieToBookmarked
: Adds a movie to the beginning of the bookmarked array. -
removeFromBookmarked
: Removes a movie from the bookmarked array based on its ID. -
addBookmarkFail
: Sets the error field with an error message while adding to the bookmark state. -
getBookmarkError
: Sets the bookmarkError field with an error message while fetching from the database. -
setUser
: Sets the user field with the payload. -
updateBookmarks
: Updates the entire bookmarked array with the payload.
In order to enable Redux's access to our reducers – the components responsible for managing state updates – we need to ensure that our redux/store.ts
file includes the appropriate import statement for bookmarkReducer
. This particular file was generated in the preceding sections of this guide.:
//store.ts
import { configureStore } from "@reduxjs/toolkit";
import bookmarkReducer from "./features/bookmarkSlice";
export const store = configureStore({
reducer: {
bookmark: bookmarkReducer,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false,
}),
devTools: process.env.NODE_ENV !== "production",
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Setting up our redux thunk
movie-bookmark/redux/features/bookmarkThunk.ts
Redux Thunk facilitates the asynchronous process of adding and removing movies from the Firebase bookmarks, as well as fetching movies from the Firebase database.
Navigate to the redux/features
directory and generate a new file named bookmarkThunk.ts
. This file is responsible for handling user authentication, movie bookmarking, and fetching bookmarked movies using Firebase's Firestore and Authentication services. After creating the bookmarkThunk.ts
file, proceed to insert the provided code below:
//bookmarkThunk.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
import { RootState } from "../store";
import { MovieThunkProp } from "../../types/movie.type";
import {
setDoc,
deleteDoc,
collection,
getDoc,
doc,
getDocs,
} from "firebase/firestore";
import { db } from "../../firebase.config";
import {
addBookmarkFail,
addMovieToBookmarked,
getBookmarkError,
removeFromBookmarked,
updateBookmarks,
} from "./bookmarkSlice";
import { signOut, signInWithPopup, GoogleAuthProvider } from "firebase/auth";
import { auth } from "../../firebase.config";
import toast from "react-hot-toast";
export const notifySuccess = (message: string) => toast.success(message);
export const notifyError = (message: string) => toast.error(message);
//user logout
export const logout = createAsyncThunk("auth/logout", async () => {
await signOut(auth);
});
//Google sign-in
export const googleSignIn = createAsyncThunk("auth/googleSignIn", async () => {
const googleAuthProvider = new GoogleAuthProvider();
await signInWithPopup(auth, googleAuthProvider);
});
//add movies bookmarks
export const addMovieToBookmarkedDB = createAsyncThunk(
"bookmark/addMovieToBookmarked",
async (movie: MovieThunkProp, { dispatch, getState }) => {
const state = getState() as RootState;
const user = state.bookmark.user;
const movieId = movie.id.toString();
const { background, date, id, poster_path, title } = movie;
try {
const bookmarkedItemRef = doc(db, `${user?.uid as string}`, movieId);
const docSnap = await getDoc(bookmarkedItemRef);
if (docSnap.exists()) {
const existItem = docSnap.data();
dispatch(
addBookmarkFail(existItem.title + " already an existing item")
);
} else {
notifySuccess(`adding ${title} to bookmarks`);
await setDoc(doc(db, `${user?.uid as string}`, movieId), {
background,
date,
id,
poster_path,
title,
});
notifySuccess(`${title} has been successfully added`);
dispatch(addMovieToBookmarked(movie));
}
} catch (error: any) {
notifyError(`failed to add ${title} ${error}`);
dispatch(
addBookmarkFail(
error.response && error.response.data.message
? error.response.data.message
: "Failed to add " + title + ": " + error.message
)
);
}
}
);
//remove movies from bookmarks
export const removeMovieFromBookmarks = createAsyncThunk(
"bookmark/removeMovieFromBookmarks",
async (id: number, { dispatch, getState }) => {
const state = getState() as RootState;
const user = state.bookmark.user;
const movieId = id.toString();
try {
dispatch(removeFromBookmarked(id));
await deleteDoc(doc(db, `${user?.uid as string}`, movieId));
notifySuccess(`Movie Id: ${id} was successfully deleted`);
} catch (error: any) {
notifyError(`failed to remove ${id}`);
dispatch({
type: "ADD_BOOKMARK_FAIL",
payload:
error.response && error.response.data.message
? error.response.data.message
: error.message,
});
}
}
);
//retrieve all bookmarked movies
export const getBookmarksFromFirebaseDB = createAsyncThunk(
"bookmark/getBookmarksFromFirebaseDB",
async (_, { getState, dispatch }) => {
const state = getState() as RootState;
const user = state.bookmark.user;
const getBookmarkItems = async (db: any) => {
const bookmarkCol = collection(db, `${user?.uid as string}`);
const bookmarkSnapshot = await getDocs(bookmarkCol);
const bookmarkList = bookmarkSnapshot.docs.map(
(doc) => doc.data() as MovieThunkProp
);
return bookmarkList;
};
try {
let allBookmarks = await getBookmarkItems(db);
dispatch(updateBookmarks(allBookmarks));
} catch (error: any) {
dispatch(
getBookmarkError(
error.response && error.response.data.message
? error.response.data.message
: error.message
)
);
}
}
);
The provided code snippet initiates with the import of essential modules, Redux state slices (addBookmarkFail
, addMovieToBookmarked
, getBookmarkError
, removeFromBookmarked
, and updateBookmarks
), and required types.
Next, we define utility helper functions for notifications: notifySuccess
and notifyError
. These functions are responsible for presenting success and error messages to users, leveraging the react-hot-toast
library.
Furthermore, we create the signOut
thunk function, responsible for managing user logout. This function executes asynchronously upon dispatch and aligns with the "auth/logout" action.
We proceed by creating the googleSignIn
thunk function, which oversees the Google sign-in procedure. It generates a GoogleAuthProvider
instance and employs the signInWithPopup
function to initialize the Google sign-in process.
Progressing onwards, the addMovieToBookmarkedDB
thunk is established. This function facilitates the addition of movies to the user's bookmarked collection as usersUid/movieId/movieData
. By deconstructing background, date, id, poster_path, title
from the movie
object, it transmits the information to Firestore. Should the movie already be present in the bookmarks, an error action is dispatched. Alternatively, if the movie is successfully added to bookmarks, the success actions are dispatched.
Moving ahead, we construct the removeMovieFromBookmarks
thunk, which manages the removal of movies from the user's bookmarked collection. This function interacts with Firestore using the movie's ID, effectively removing it from the collection. Upon successful removal, a notification indicating success is displayed;otherwise, if an issue arises, an error action is dispatched.
Lastly, the getBookmarkFromDB
thunk is created to retrieve all bookmarked movies from the Firestore database. Subsequently, it updates the Redux state to reflect the changes.
Making redux accessible in our application
movie-bookmark/context/redux.provider.tsx
After successfully creating our Redux slice and thunk, the next step is to ensure that Redux is accessible throughout our entire application.
Navigate to the root directory of movie-bookmark
and establish a new folder named context
. Within this folder, create a file named redux.provider.tsx
, and insert the provided code snippet:
//redux.provider.tsx
"use client";
import { store } from "../redux/store";
import { Provider } from "react-redux";
export function Providers({ children }: { children: React.ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
The code above shows the intergration of the Redux store into our Nextjs application using the Provider
component.
From the code above, we utilize the use-client
directive to classify this as a client-side component. We then import the store
that was created in the earlier section of this article. The Provider
component is employed, accepting a children
prop that enables the rendering of nested components within the context of the Redux store.
After completing the previous step, navigate to the src/app/layout.tsx
file to implement our Provider component within the context of our Next.js application.
//layout.tsx
import "./globals.css";
import type { Metadata } from "next";
import { Montserrat } from "@next/font/google";
import { Inter } from "next/font/google";
import { Providers } from "../../context/redux.provider";
const monstserrat = Montserrat({
weight: ["400", "700"],
subsets: ["latin"],
variable: "--font-monstserrat",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang='en'>
<body className={`${monstserrat.className} block items-center`}>
<Providers>
{children}
</Providers>
</body>
</html>
);
}
In the provided code snippet, we've introduced the Provider
component, which serves as a wrapper for our application's component structure. Additionally, we've set up the Montserrat
font as our chosen font.
Creating our components
movie-bookmark/src/app/components/AnimationWrapper.tsx
Having made redux accessible accross our application, we'll dive into creating our component.
First we create the AnimationWrapper.tsx
component which takes in a children
props and create an initial animate
and exit
animations using the framer-motion
library.
//AnimationWrapper.tsx
import React, { ReactNode } from "react";
import { motion } from "framer-motion";
const animations = {
initial: { opacity: 0, x: 10 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -10 },
};
const AnimatedWrapper = ({ children }: { children: ReactNode }) => {
return (
<motion.div
variants={animations}
initial='initial'
animate='animate'
exit='exit'
transition={{ duration: 1 }}
>
{children}
</motion.div>
);
};
export default AnimatedWrapper;
movie-bookmark/src/app/Header.tsx
Moving forward, after creating our AnimationWrapper.tsx
component, our next step is to develop the Header.tsx
client-component. This component serves multiple purposes:
- It manages user authentication within the
useEffect
function, triggering asetUser
action. - The user information is extracted from the
user
state using theuseAppSelector
hook and displayed appropriately. - For seamless navigation to the
bookmark
page, a link is provided, utilizing theusePathname
hook. - Additionally, this component facilitates the dispatching of
logout
andgoogleSignIn
thunks, effectively handling potential errors that may arise. - To ensure up-to-date notification, the
getBookmarksFromFirebaseDB()
thunk is invoked. This action continually updates the count of bookmarked movies, which is then indicated as a badge on the bookmark icon.
//Header.tsx
"use client";
import { useState, useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { googleSignIn } from "../../../redux/features/bookmarkThunk";
import { setUser } from "../../../redux/features/bookmarkSlice";
import { usePathname } from "next/navigation";
import { onAuthStateChanged } from "firebase/auth";
import {
getBookmarksFromFirebaseDB,
logout,
} from "../../../redux/features/bookmarkThunk";
import { auth } from "../../../firebase.config";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import Image from "next/image";
import Link from "next/link";
const Header = () => {
const pathname = usePathname();
const dispatch = useAppDispatch();
const user: any = useAppSelector((state) => state.bookmark.user);
const bookmarkedMovies = useAppSelector((state) => state.bookmark.bookmarked);
const [firebaseError, setFirebaseError] = useState<string>("");
const handleGoogleSignIn = async () => {
try {
await dispatch(googleSignIn());
} catch (error: any) {
setFirebaseError(error.message);
}
};
const handleLogout = () => {
try {
dispatch(logout());
} catch (error: any) {
setFirebaseError(error.message);
}
};
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (currentUser: any) => {
dispatch(setUser(currentUser));
});
dispatch(getBookmarksFromFirebaseDB());
return () => {
unsubscribe();
};
}, [dispatch]);
const length = bookmarkedMovies.length;
return (
<div className='flex px-4 py-4 items-center bg-black border-b-2 border-slate-600 justify-between'>
{firebaseError && (
<h3 className='text-red-600 bg-red-100 px-2 py-2 rounded-md text-sm w-[20em]'>
{firebaseError}
</h3>
)}
<Link href='/'>
<h3 className='text-sm text-orange-600'>
my<span className='text-white'>Bookmarks</span>
</h3>
</Link>
<div className='flex gap-3'>
<button
className='px-3 py-2 bg-blue-600 text-sm text-white rounded-full'
onClick={user ? handleLogout : handleGoogleSignIn}
>
{user ? "Sign out" : "Sign in"}
</button>
{user && (
<Image
src={user?.photoURL ? user?.photoURL : ""}
alt={user?.email ? user?.email : ""}
width={500}
height={500}
className='w-[30px] h-[30px] rounded-full text-white via-cyan-900 to-stone-500 bg-gradient-to-r max-sm:cursor-pointer'
data-cy='user-profile-image'
priority
/>
)}
<Link href='/bookmarks'>
<button
className={`${
pathname === "/bookmarked"
? "text-orange-400 font-semibold"
: "text-white"
} block relative`}
data-cy='bookmark-icon'
>
<span>
<BookmarkBorderIcon />
</span>
{length && (
<span className='absolute -top-[8px] -right-[10px] w-5 h-5 bg-red-600 rounded-full flex items-center font-normal justify-center text-white text-xs'>
{user ? `${length}` : "0"}
</span>
)}
</button>
</Link>
</div>
</div>
);
};
export default Header;
The Header component must be positioned at the top of our web application. To achieve this, navigate to the src.app/layout.tsx
file and import the Header component. By placing this component before any child elements, you ensure its display at the top.
//layout.tsx
import "./globals.css";
import type { Metadata } from "next";
import { Montserrat } from "@next/font/google";
import { Inter } from "next/font/google";
import { Providers } from "../../context/redux.provider";
import Header from "./components/Header";
const monstserrat = Montserrat({
weight: ["400", "700"],
subsets: ["latin"],
variable: "--font-monstserrat",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang='en'>
<body className={`${monstserrat.className} block items-center`}>
<Providers>
<Header />
{children}
</Providers>
</body>
</html>
);
}
movie-bookmark/src/app/MovieCard.tsx
After successfully creating our Header.tsx
component, let's proceed to the final component: the MovieCard.tsx
component.
This component is designed to display a movie card that includes essential information such as the movie title, release date, poster image, rating (all of which are destructured into our component) and the ability to be bookmarked. The main functionalities of this component are as follows:
Bookmarking Feature: The component integrates the
addMovieToBookmarkedDB()
andremoveMovieFromBookmarks()
thunks, which are responsible for managing the addition and removal of movies from the Firestore database when dispatched.Existing Movie Check: The component also performs a check to determine if a movie is already present in the bookmark collection using the
checkIfItemExists
function. It updates the existing state using thesetExists
function. This update occurs within theuseEffect
hook, which is triggered whenever an item is added to or removed from the Firestore database.
The user interface (UI) of the component showcases key elements such as the movie poster, title, rating, and release year. Additionally, a bookmark button is provided, allowing users to toggle between bookmarked and unbookmarked states for a particular movie. This dynamic behavior is facilitated by checking the existence of the movie in the Firestore database.
//MovieCard.tsx
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import { useEffect, useState } from "react";
import { useAppSelector, useAppDispatch } from "../../../redux/hooks";
import {
addMovieToBookmarkedDB,
removeMovieFromBookmarks,
} from "../../../redux/features/bookmarkThunk";
import { MovieCardProps } from "../../../types/movie.type";
import { collection, getDocs } from "firebase/firestore";
import { db } from "../../../firebase.config";
import { Toaster } from "react-hot-toast";
import StarIcon from "@mui/icons-material/Star";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import BookmarkIcon from "@mui/icons-material/Bookmark";
import Image from "next/image";
export default function MovieCard({
title,
poster_path,
release_date,
movieId,
backdrop_path,
movieRating,
user,
}: MovieCardProps) {
const imagePath = "https://image.tmdb.org/t/p/original";
const [exists, setExists] = useState(false);
const bookmarks = useAppSelector((state) => state.bookmark.bookmarked);
const dispatch = useAppDispatch();
const movieData = {
background: backdrop_path,
date: release_date,
poster_path: poster_path,
id: movieId,
title: title,
};
const checkIfItemExists = async () => {
const bookmarkCol = collection(db, `${user?.uid as string}`);
const bookmarkSnapshot = await getDocs(bookmarkCol);
const bookmarkList = bookmarkSnapshot.docs.map((doc) => doc.data());
const itemExists = bookmarkList.some((item) => item.id === movieId);
setExists(itemExists);
};
const handleAddToBookmark = (movie: any) => {
try {
if (user) {
dispatch(addMovieToBookmarkedDB(movie));
}
} catch (error) {
console.log(error);
}
};
const handleRemoveMovieFromBookmark = (id: number) => {
if (user) {
dispatch(removeMovieFromBookmarks(id));
}
};
useEffect(() => {
checkIfItemExists();
}, [exists, bookmarks]);
return (
<div className='w-fit mt-[20px]'>
<Toaster />
<div className='w-[250px]'>
<Image
src={imagePath + poster_path}
alt={title || "movie"}
className='h-[350px] w-[250px] max-sm:w-[350px] bg-stone-300 transition ease-in-out cursor-pointer hover:brightness-50'
loading='lazy'
width={500}
height={500}
blurDataURL={imagePath + backdrop_path}
placeholder='blur'
/>
<section className='flex items-center justify-between'>
<div className='block'>
<h1 className='mt-3 text-sm text-white font-semibold tracking-tight'>
{title}
</h1>
<p className='text-sm flex gap-3 text-slate-400 font-normal mt-1'>
<span>
<StarIcon
style={{ fontSize: "16px" }}
className='text-orange-600'
/>
{movieRating?.toFixed(1)}
</span>
<span>|</span>
<span> {release_date?.substring(0, 4)}</span>
</p>
</div>
<button
className='px-3 py-2 text-blue-500 rounded-full hover:text-blue-400'
onClick={() => {
if (exists) {
handleRemoveMovieFromBookmark(movieId);
} else {
handleAddToBookmark(movieData);
}
}}
>
{exists ? (
<BookmarkIcon
style={{ fontSize: "20px" }}
className='text-blue-300 cursor-pointer'
/>
) : (
<BookmarkBorderIcon
style={{ fontSize: "20px" }}
className='text-blue-300 cursor-pointer'
/>
)}
</button>
</section>
</div>
</div>
);
}
In the given code, you will notice that we are using an imagePath
to display our TMDB images. To enable Next.js to access the domain endpoint, we'll configure the domains used in this tutorial within our next.config.ts
file. This includes both the TMDB
and GOOGLE API
domains.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['image.tmdb.org', 'lh3.googleusercontent.com']
}
}
module.exports = nextConfig
Creating our movies page
movie-bookmark/src/app/page.tsx
Before we start creating our movie page, we must provide it with the necessary movie data. In this tutorial, we will utilize static movie data acquired from the TMDB API.
To get started, navigate to the root directory named movie-bookmark
. Inside the data
folder, go ahead and create a file called movie.ts
. Once you've completed this step, insert the following static movie data:
//data/movie.ts
export const results = [
{
adult: false,
backdrop_path: "/znUYFf0Sez5lUmxPr3Cby7TVJ6c.jpg",
id: 447277,
title: "The Little Mermaid",
original_language: "en",
original_title: "The Little Mermaid",
poster_path: "/ym1dxyOk4jFcSl4Q2zmRrA5BEEN.jpg",
media_type: "movie",
genre_ids: [12, 10751, 14, 10749],
popularity: 2078.238,
release_date: "2023-05-18",
video: false,
vote_average: 6.346,
vote_count: 1015,
},
{
adult: false,
backdrop_path: "/ctMserH8g2SeOAnCw5gFjdQF8mo.jpg",
id: 346698,
title: "Barbie",
original_language: "en",
original_title: "Barbie",
poster_path: "/iuFNMS8U5cb6xfzi51Dbkovj7vM.jpg",
media_type: "movie",
genre_ids: [35, 12, 14],
popularity: 6058.224,
release_date: "2023-07-19",
video: false,
vote_average: 7.631,
vote_count: 1130,
},
{
adult: false,
backdrop_path: "/7drO1kYgQ0PnnU87sAnBEphYrSM.jpg",
id: 1083862,
title: "Resident Evil: Death Island",
original_language: "ja",
original_title: "バイオハザード:デスアイランド",
poster_path: "/qayga07ICNDswm0cMJ8P3VwklFZ.jpg",
media_type: "movie",
genre_ids: [16, 28, 27],
popularity: 813.767,
release_date: "2023-06-22",
video: false,
vote_average: 8.347,
vote_count: 160,
},
{
adult: false,
backdrop_path: "/fm6KqXpk3M2HVveHwCrBSSBaO0V.jpg",
id: 872585,
title: "Oppenheimer",
original_language: "en",
original_title: "Oppenheimer",
poster_path: "/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
media_type: "movie",
genre_ids: [18, 36],
popularity: 1449.266,
release_date: "2023-07-19",
video: false,
vote_average: 8.385,
vote_count: 668,
},
{
adult: false,
backdrop_path: "/yF1eOkaYvwiORauRCPWznV9xVvi.jpg",
id: 298618,
title: "The Flash",
original_language: "en",
original_title: "The Flash",
poster_path: "/rktDFPbfHfUbArZ6OOOKsXcv0Bm.jpg",
media_type: "movie",
genre_ids: [28, 12, 878],
popularity: 5930.081,
release_date: "2023-06-13",
video: false,
vote_average: 6.923,
vote_count: 1603,
},
{
adult: false,
backdrop_path: "/pMCvRynXABgLBMKHYa2UXjTBMsU.jpg",
id: 615,
title: "Futurama",
original_language: "en",
original_name: "Futurama",
poster_path: "/7RRHbCUtAsVmKI6FEMzZB6Re88P.jpg",
media_type: "tv",
genre_ids: [16, 35, 10765],
popularity: 677.546,
release_date: "1999-03-28",
vote_average: 8.398,
vote_count: 2755,
origin_country: ["US"],
},
{
adult: false,
backdrop_path: "/sa9vB0xb3OMU6iSMkig8RBbdESq.jpg",
id: 113962,
title: "Special Ops: Lioness",
original_language: "en",
original_name: "Special Ops: Lioness",
poster_path: "/rXCzevakJoAN1qnZY0nAQPSLVRv.jpg",
media_type: "tv",
genre_ids: [18],
popularity: 270.057,
release_date: "2023-07-23",
vote_average: 8.2,
vote_count: 41,
origin_country: ["US"],
},
{
adult: false,
backdrop_path: "/2vFuG6bWGyQUzYS9d69E5l85nIz.jpg",
id: 667538,
title: "Transformers: Rise of the Beasts",
original_language: "en",
original_title: "Transformers: Rise of the Beasts",
poster_path: "/gPbM0MK8CP8A174rmUwGsADNYKD.jpg",
media_type: "movie",
genre_ids: [28, 12, 878],
popularity: 5458.192,
release_date: "2023-06-06",
video: false,
vote_average: 7.469,
vote_count: 1909,
},
{
adult: false,
backdrop_path: "/kIMYSzp1fH1H9adKplekLD9BuNi.jpg",
id: 1003581,
title: "Justice League: Warworld",
original_language: "en",
original_title: "Justice League: Warworld",
poster_path: "/9tx4cD3cuHhrdqLwFw8TTbfSKH2.jpg",
media_type: "movie",
genre_ids: [16, 28, 878],
popularity: 121.482,
release_date: "2023-07-25",
video: false,
vote_average: 7.421,
vote_count: 19,
},
{
adult: false,
backdrop_path: "/av2wp3R978lp1ZyCOHDHOh4FINM.jpg",
id: 736769,
title: "They Cloned Tyrone",
original_language: "en",
original_title: "They Cloned Tyrone",
poster_path: "/hnzXoDaK346U4ByfvQenu2DZnTg.jpg",
media_type: "movie",
genre_ids: [35, 878, 9648],
popularity: 128.81,
release_date: "2023-06-14",
video: false,
vote_average: 7.022,
vote_count: 115,
},
];
After inserting the movie data, the movie page component functions as the primary webpage of the application and is reachable by navigating to the route localhost:3000
. Within this component, our initial task is to substitute the default JSX code that is created by Next.js upon installation. Instead, we will integrate the subsequent code:
"use client";
import { useEffect } from 'react';
import { results } from "../../data/movie";
import { useAppSelector } from "../../redux/hooks";
import MovieCard from "./components/MovieCard";
import AnimatedWrapper from "./components/AnimationWrapper";
import { getBookmarksFromFirebaseDB } from "../../redux/features/bookmarkThunk";
import { useAppDispatch } from '../../redux/hooks';
export default function Home() {
const user: any = useAppSelector((state) => state.bookmark.user);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(getBookmarksFromFirebaseDB());
}, [dispatch])
return (
<>
<div className='flex flex-col items-center justify-center h-auto px-4 py-4'>
<AnimatedWrapper>
<div className='grid grid-cols-4 max-md:grid-cols-2 gap-6 items-center max-sm:flex max-sm:justify-center max-sm:flex-col'>
{results.map((movie) => {
return (
<div key={movie?.id}>
<MovieCard
title={movie?.title as string}
movieId={movie?.id as number}
poster_path={movie?.poster_path as string}
backdrop_path={movie?.backdrop_path as string}
release_date={movie?.release_date as string}
movieRating={movie?.vote_average as number}
user={user}
/>
</div>
);
})}
</div>
</AnimatedWrapper>
</div>
</>
);
}
The provided code snippet involves importing various components and data. We first import the static data from data/movie.ts
, which contains movie information. Additionally, we import the MovieCard
component, previously designed for presenting movie details, and the AnimationWrapper
component, responsible for adding a fading effect to our movie cards.
Next, we proceed to iterate through the movie data. During this iteration, we generate individual instances of the MovieCard
for each movie. We ensure that pertinent details like title, IDs, paths, dates, ratings, and user information are appropriately passed on.
To maintain the accurate display of bookmarked movies present in Firestore, we employ the getBookmarksFromDB
thunk. This thunk is dispatched and triggers updates whenever a re-render occurs. By employing this approach, we guarantee that movies existing in Firestore and marked as bookmarks are correctly displayed on the page.
Creating our bookmarks page
movie-bookmark/src/app/bookmarks/page.tsx
After completing the setup of our movies page, our next step involves establishing the bookmark page functionality. This page is responsible for retrieving bookmarks from the Firebase database and displaying them.
To proceed, navigate to the src/app/bookmarks
directory. Inside this directory, create a new file named page.tsx
. Once the file is created, insert the provided code:
"use client";
import { useState, useEffect } from "react";
import {useAppSelector, useAppDispatch } from "../../../redux/hooks";
import {
getBookmarksFromFirebaseDB,
removeMovieFromBookmarks,
} from "../../../redux/features/bookmarkThunk";
import { Toaster } from "react-hot-toast";
import DeleteIcon from "@mui/icons-material/Delete";
import AnimatedWrapper from "../components/AnimationWrapper";
import Image from "next/image";
const Bookmark = () => {
const imagePath = "https://image.tmdb.org/t/p/original";
const bookmarkedMovies = useAppSelector((state) => state.bookmark.bookmarked);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(getBookmarksFromFirebaseDB());
}, [dispatch]);
return (
<AnimatedWrapper>
<div className='flex flex-col items-center justify-center px-6 py-6 w-[100vw] overflow-x-hidden'>
<Toaster />
<h1 className='font-semibold mb-[20px] text-white'>My Bookmarks</h1>
<div>
{bookmarkedMovies?.length === 0 ? (
<h2>Sorry no bookmarks :(</h2>
) : (
<>
<div className='grid grid-cols-4 max-md:grid-cols-2 gap-6 items-center max-sm:flex max-sm:justify-center max-sm:flex-col'>
{bookmarkedMovies?.map((movie: any) => (
<div key={movie?.id} className='w-[250px]'>
<Image
src={imagePath + movie?.poster_path}
alt={`${movie?.title || ""}`}
className='h-[350px] w-[250px] bg-stone-300 transition ease-in-out cursor-pointer hover:brightness-50'
loading='lazy'
width={500}
height={500}
blurDataURL={imagePath + movie?.poster_path}
placeholder='blur'
/>
<div className='flex gap-2 relative -mt-[20em] float-right px-2'>
<button
title='bookmark movie'
className={`text-xs bg-white text-slate-500 px-3 py-3 hover:scale-110 transition ease-in-out rounded-full`}
onClick={() =>
dispatch(removeMovieFromBookmarks(movie?.id))
}
>
<DeleteIcon className='text-red-500' />
</button>
</div>
<h1 className='mt-3 text-sm text-white font-semibold tracking-tight'>
{movie?.title}
</h1>
<p className='text-slate-400 font-normal mt-1'>
<span>{movie?.date?.substring(0, 4)}</span>
</p>
</div>
))}
</div>
</>
)}
</div>
</div>
</AnimatedWrapper>
);
};
export default Bookmark;
In the provided code snippet, we retrieve the bookmarked movie from Firestore by executing the getBookmarksFromFirebaseDB
thunk. This action updates the state of our bookmarkMovies
. If users wish to remove movies from their bookmarks, this action is initiated through Redux, specifically using the removeMovieFromBookmarks
action.
Running our application
Congratulations! You've made it to this point. To witness our application in action, follow these steps:
- Open your command prompt.
- Enter the following command to run the application:
npm run dev
You can view your application live by accessing at http://localhost:3000
.
Conclusion
This article guides you through the process of configuring and utilizing Redux Toolkit and Firebase within your Next.js 13.4 application. Furthermore, we illustrate the creation of a functional movie bookmark feature, showcasing the prowess of the Redux store in your Next.js project. By integrating these tools, you can proficiently handle your application's state and retrieve data from Firebase in an organized and streamlined manner.
I trust that you have found this tutorial beneficial. If you possess any feedback or questions, kindly share them in the comments section.
Posted on August 31, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.