DEV Community

Cover image for How to build an e-commerce website using Next.js, Xata, and Cloudinary.
Stephanie Opala for Hackmamba

Posted on • Updated on

How to build an e-commerce website using Next.js, Xata, and Cloudinary.

This article is a step-by-step guide on how to build an e-commerce website using NextJs, Xata, and Cloudinary. Xata is a serverless database; on the other hand, Cloudinary is a platform used to manage media, that is, images, videos, etc. The other technologies required to build this application include Redux Toolkit and Redux Persist for state management.

Prerequisites

To follow along, you will need to have:

  • Basic knowledge of Next JS.

Getting started

Create a Next.js application using the following command.

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

Creating a Cloudinary account

Go to Cloudinary and sign up for an account. If you already have an account, log in. Go to the Media Library section. Click on the + icon on your top left as shown below and create a folder containing the application's images.

Media Library

Double-click to open the folder. Next, upload images either by using the Upload button at the top right corner of the website or drag-and-drop your files if the folder is empty.

Upload

Setting up Xata

First, create an account if you do not have one. Go to Xata and sign up or log in. Once logged in, the landing page is the workspace where you will create your databases. Click on Add a database and give it a name. For the region, you can leave the default.

Xata Workspace

Xata workspace

Creating tables

We will have two tables, namely, product and product_category. Click Add a table on the menu on your left and add a table named product, which will contain products.

Add table

This table will have the following columns; id, name, description, category, image_url, and price. You will notice that the id column is created automatically because it is a mandatory column. To create a column, click on the + icon and select the type, e.g String.

Create a column

A pop-up will appear. Fill in the details, that is, the column name, Unique, and Not null checkboxes, and then click on create the column.

Creating a column

For the name column, the type will be a string and unique. The description column will be of a string type and will not be unique. The category column will be of a link type and linked to the product_category table. The image_url will be of a string type and will be unique. Lastly, the price column will be of an integer number or a decimal number type, not unique.

The product_category table will only have two columns, id, and name. The name column will be of a string type and unique.

Adding records to the tables

Our application requires data; in this case, the data will be a list of products. In the products table, click the Add a record button, and a pop-up will appear. Fill in the details and click on the create record button once you are done.

Add a record

A record in this database is a single product containing information such as the name, description, category, price etc, according to the columns we created earlier. Add as many records as you need. Do the same for the product_category table.

Connecting Xata to Next.js

In your terminal, run the following command to install the Xata CLI globally.

npm i -g @xata.io/cli
Enter fullscreen mode Exit fullscreen mode

The CLI needs to be authenticated to connect to the database using the Xata CLI. This can be done either globally or project-based. For this project, we will use project-based authentication.

Go to the Xata Web UI and then to your workspace. Next, navigate to the configuration section and the workspace API base URL.

Configuration

Copy the URL and replace the {region} and {database} with the region and your database name, respectively. Back in the terminal, run the following command to initialize the authentication process. Replace databaseUrl with the URL that you copied earlier.

xata init --db=databaseUrl
Enter fullscreen mode Exit fullscreen mode

When you run this command, you will be redirected to the browser, where a new API key will be created. You will be prompted to name the API key. Once that is done, a project configuration file will be created in your current working directory, your .env file will be updated with the API key, and you will be asked if you want to install the SDK and/or use the TypeScript/JavaScript code generator. Choose the latter as it is recommended.

Setting up Redux Toolkit and Redux Persist

We will use Redux Toolkit to manage the state of the cart in our application. On the other hand, we will use redux to persist the state of our application so that when you reload the page, the state is not lost. Run this command in your terminal to install redux toolkit and redux persist.

npm install @reduxjs/toolkit react-redux redux-persist
Enter fullscreen mode Exit fullscreen mode

Creating a slice

Create a folder, redux, at the root of your project folder. Next, create a file named cartSlice.js.

    import { createSlice } from '@reduxjs/toolkit';

    const initialState = {
     cart: []
    };

    export const cartSlice = createSlice({
     name: 'cart',
     initialState,
     reducers: {
      addToCart: (state, action) => {
       const productExists = state.cart?.find((product) => product.id === action.payload.id);
       if(productExists) {
        productExists.quantity++;
       } else {
        state.cart.push({
         ...action.payload, quantity: 1
        })
       }
      },
      incrementQuantity: (state, action) => {
       const product = state.cart?.find((item) => item.id === action.payload.id);
       product.quantity++;
      },
      decrementQuantity: (state, action) => {
       const product = state.cart?.find((item) => item.id === action.payload.id);
       if (product.quantity === 1) {
        const index = state.cart.findIndex((item) => item.id === action.payload.id);
        state.cart.splice(index, 1);
       } else {
        product.quantity--;
       }
      },
      removeFromCart: (state, action) => {
       const index = state.cart?.findIndex((item) => item.id === action.payload.id);
       state.cart.splice(index, 1);
      }
     }
    });

    export const {
     addToCart,
     removeFromCart,
     incrementQuantity,
     decrementQuantity
    } = cartSlice.actions;

    export default cartSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

At the top of our file, we import createSlice from the redux toolkit. A slice contains the name, initialState, and reducer functions. Our slice has four reducer functions; addToCart, incrementQuantity, decrementQuantity and removeFromCart.

The addToCart function takes state and action parameters. This function checks to see if a product exists. If it exists, the quantity increases by one; if it doesn’t, the state is updated with the new product, and the quantity is one.

The incrementQuantity function increases the quantity of the product by one.

The decrementQuantity function checks if the product quantity is equal to one. If so, the product is removed from the cart. If not, the quantity of the product is reduced by one.

The last reducer function is removeFromCart which finds the index of the product in the cart and we the splice() method to remove it.

At the bottom of the file, we export the actions and reducer to use them in other files in our application.

Creating a store

Inside the redux folder, create a store.js file and add the following code;

    import { configureStore } from "@reduxjs/toolkit";
    import cartReducer from './cartSlice';
    import {
     persistReducer,
     persistStore
    } from 'redux-persist';
    import thunk from "redux-thunk";
    import createWebStorage from "redux-persist/lib/storage/createWebStorage";

    const createNoopStorage = () => {
     return {
      getItem(_key) {
       return Promise.resolve(null);
      },
      setItem(_key, value) {
       return Promise.resolve(value);
      },
      removeItem(_key) {
       return Promise.resolve();
      },
     };
    };
    const storage = typeof window !== "undefined" ? createWebStorage("local") : createNoopStorage();
    export default storage;

    const persistConfig = {
     key: 'root',
     version: 1,
     storage
    };
    const persistedReducer = persistReducer(persistConfig, cartReducer);

    export const store = configureStore({
     reducer: persistedReducer,
     middleware: [thunk]
    });

    export const persistor = persistStore(store);
Enter fullscreen mode Exit fullscreen mode

Providing state to the application

We will wrap our App component with a Provider to access the state from anywhere in our application. Go to the pages folder and then _app.js, and add the following code.

    import '../styles/global.css';
    import { Provider } from 'react-redux';
    import { store, persistor } from '../redux/store';
    import { PersistGate } from 'redux-persist/integration/react';

    const App = ({ Component, pageProps}) => {
     return (
      <Provider store={store}>
       <PersistGate loading={null} persistor={persistor}>
        <div className="app">
         <Component {...pageProps} />
        </div>
       </PersistGate>
      </Provider>
     )
    }
    export default App;
Enter fullscreen mode Exit fullscreen mode

Creating the navbar and footer components

Create a components folder at the root of your project.
Inside this folder, create these two files; Navbar.jsx and Footer.jsx
In the Navbar.jsx file, add the following code.

    import Link from 'next/link';
    import styles from '../styles/Navbar.module.css';
    import { useSelector } from 'react-redux';

    const Navbar = () => {
     const cart = useSelector((state) => state.cart);
     const sumOfCartItems = () => {
      return cart?.reduce((accumulator, item) => accumulator + item.quantity, 0);
     };
     return (
      <nav className={styles.navbar}>
       <h4 className={styles.logo}>Kwetu Furniture</h4>
       <ul className={styles.navlinks}>
        <li className={styles.navlink}>
         <Link href="/">Home</Link>
        </li>
        <li className={styles.navlink}>
         <Link href="/shop">Shop</Link>
        </li>
        <li className={styles.navlink}>
         <Link href="/cart">
          <p className={styles.cart}>
           Cart <span>{sumOfCartItems()}</span>
          </p>
         </Link>
        </li>
       </ul>
      </nav>
     )
    }
    export default Navbar
Enter fullscreen mode Exit fullscreen mode

At the top, we import the useSelector hook and use it later in the code to access the state in our store. This file also contains the navigation links to our pages, including Home, Shop, and Cart. For styling, we will use CSS modules. Go to the styles folder, create a file named Navbar.module.css and add the following code. The CSS file is imported in Navbar.jsx as shown above, to apply the styles.

    .navbar {
     width: '100%';
     display: flex;
     justify-content: space-between;
     align-items: center;
     padding-bottom: 1rem;
    }
    .logo {
     font-size: 1.2rem;
     font-weight: 600;
     color: #654236
    }
    .navlinks {
     display: flex;
    }
    .navlink {
     list-style: none;
     margin: 0 0.75rem;
     text-transform: uppercase;
    }
    .navlink a {
     text-decoration: none;
     color: #654236;
    }
    .navlink a:hover {
     color: #f9826c;
    }
Enter fullscreen mode Exit fullscreen mode

In the Footer.jsx file, add the following code.

    import styles from '../styles/Footer.module.css';

    const Footer = () => {
     return (
      <footer className={styles.footer}>
       Kwetu &copy; 2022
      </footer>
     )
    }
    export default Footer
Enter fullscreen mode Exit fullscreen mode

In the styles folder, create a Footer.module.css file and add these styles. This file is imported in the Footer.jsx file, as shown above.

    .footer {
     text-align: center;
     color: #654236;
    }
Enter fullscreen mode Exit fullscreen mode

Navigate to _app.js file inside the pages folder and add the following code.

    import Navbar from '../components/Navbar';
    import Footer from '../components/Footer';
    import '../styles/global.css';
    import { Provider } from 'react-redux';
    import { store, persistor } from '../redux/store';
    import { PersistGate } from 'redux-persist/integration/react';

    const App = ({ Component, pageProps}) => {
     return (
      <Provider store={store}>
       <PersistGate loading={null} persistor={persistor}>
        <div className="app">
         <Navbar />
         <Component {...pageProps} />
         <Footer />
        </div>
       </PersistGate>
      </Provider>
     )
    }
    export default App;
Enter fullscreen mode Exit fullscreen mode

We import the Navbar and Footer components in this file to appear on all of our pages in the application. Add the following in the global.css file in the styles folder for the global styles.

    @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500&display=swap');
    * {
     margin: 0;
     padding: 0;
     box-sizing: border-box;
     font-family: 'Poppins', sans-serif;
    }
    .app {
     padding: 2rem;
     min-height: 100vh;
     display: flex;
     flex-direction: column;
     justify-content: space-between;
    }
    @media only screen and (max-width: 768px) {
     .app {
      padding: 1rem;
     }
    }
Enter fullscreen mode Exit fullscreen mode

Creating the ProductCard component

This is a card component that will display information about a product. Inside the components folder, create a ProductCard.jsx file. Add the code below.

    import Image from 'next/image';
    import Link from 'next/link';
    import styles from '../styles/ProductCard.module.css';
    import { useDispatch } from 'react-redux';
    import { addToCart } from '../redux/cartSlice';

    export const ProductCard = ({url, id, name, price, categoryName, product}) => {
     const dispatch = useDispatch();
     return (
      <div className={styles.card}>
       <Image className={styles.image}
        src={url}
        width={320}
        height={300}
       />
       <h4 className={styles.name}>{name}</h4>
       <h5 className={styles.price}>${price}</h5>
       <h6 className={styles.category}>{categoryName}</h6>
       <div className={styles.buttons}>
        <Link href={`/shop/${id}`}>
         <button className={styles.buttonone}>
          Product Details
         </button>
        </Link>
        <button
         className={styles.buttontwo}
         onClick={() => dispatch(addToCart(product))}
        >
         Add to cart
        </button>
       </div>
      </div>
     )
    };
Enter fullscreen mode Exit fullscreen mode

This component accepts the following props;

URL, id, name, price, categoryName, product

It also has a link to the Product Details page that we will create next and a button to add the product to the cart. On click of the Add to Cart button, we dispatch the addToCart action and pass the product as the payload.
To style this component, create a ProductCard.module.css file in the styles folder and add these styles.

    .card {
     width: 280px;
     height: 380px;
     padding: 1rem 1.2rem;
     border-radius: 8px;
     display: flex;
     flex-direction: column;
     align-items: center;
     justify-content: space-between;
    }
    .card:hover {
     box-shadow: rgba(50, 50, 93, 0.25) 0px 6px 12px -2px, rgba(0, 0, 0, 0.3) 0px 3px 7px -3px;
    }
    .buttons {
     width: '100%';
     display: flex;
     justify-content: space-between;
    }
    .name {
     color: #654236;
    }
    .price {
     color: #E24E1B;
    }
    .category {
     color: #654236;
     margin-bottom: 15px;
    }
    .buttonone, .buttontwo {
     background-color: #E24E1B;
     color: white;
     padding: 10px 10px;
     border-radius: 6px;
     border: 1px solid #E24E1B;
    }
    .buttonone:hover {
     background-color: white;
     color: #E24E1B;
     border: 1px solid #E24E1B;
     cursor: pointer;
    }
    .buttonone {
     margin-right: 20px;
    }
    .buttontwo:hover {
     background-color: white;
     color: #E24E1B;
     border: 1px solid #E24E1B;
     cursor: pointer;
    }
Enter fullscreen mode Exit fullscreen mode

Adding pages

As mentioned earlier, our Next.js application will have three pages; Home, Shop, and Cart. We will start with creating the Home page.
Navigate to the index.js file in the pages folder and replace the boilerplate code with the following code.

    import Image from 'next/image';
    import Head from 'next/head';
    import Link from 'next/link';
    import styles from '../styles/Home.module.css';

    const HomePage = () => {
     return (
      <>
       <Head>
        <title>Kwetu Furniture | Home</title>
       </Head>
       <div className={styles.container}>
        <div className={styles.sectionone}>
         <h4 className={styles.title}>
          Home of elegant, stylish and affordable furniture
         </h4>
         <Link href="/shop">
          <button className={styles.button}>
           Shop Now
          </button>
         </Link>
        </div>
        <div className={styles.sectiontwo}>
         <Image
          className={styles.image}
          src={`https://res.cloudinary.com/${process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}/image/upload/v1666527186/ecommerce/couch_yqi3f0.jpg`}
          alt="couch"
          layout='fill'
         />
        </div>
       </div>
      </>
     )
    }
    export default HomePage;
Enter fullscreen mode Exit fullscreen mode

This page contains an image that is served from Cloudinary. To get the link to your image, go to your Cloudinary account and open the folder that contains your images. Once you have an image, you would like to use for the home page, click on the three dots and copy the URL.

Image URL

The URL contains your Cloudinary name, which will be stored in a .env.local file in our Next.js app. Create a .env.local file at the root of your project. Inside this file, add the following.

NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=yourcloudname

Replace yourcloudname with your Cloudinary cloud name.

In the index.js file, update the URL with process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME as shown below.

<Image
 className={styles.image}
 src={`https://res.cloudinary.com/${process.env.NEXT_PUBLIC_CLOUDINARCLOUD_NAME}/image/upload/v1666527186/ecommerce/couch_yqi3f0.jpg`}
 alt="couch"
 layout='fill'
/>
Enter fullscreen mode Exit fullscreen mode

To style this page, create a Home.module.css file in the styles folder and add the styling.

    .container {
     display: grid;
     grid-template-columns: 1fr 1fr;
     column-gap: 30px;
    }
    .title {
     font-size: xx-large;
     margin-bottom: 20px;
     color: #654236;
    }
    .sectionone, .sectiontwo {
     height: 70vh;
    }
    .sectionone {
     width: '100%';
     display: flex;
     flex-direction: column;
     justify-content: center;
    }
    .sectiontwo {
     position: relative;
    }
    .image {
     border-radius: 12px 12px 12px 12px;
    }
    .button {
     width: 120px;
     background-color: #E24E1B;
     color: white;
     padding: 10px 12px;
     border-radius: 6px;
     border: 1px solid #E24E1B;
    }
    .button:hover {
     background-color: white;
     color: #E24E1B;
     cursor: pointer;
     border: 1px solid #E24E1B;
    }
    @media only screen and (max-width: 768px) {
     .container {
      grid-template-columns: 1fr;
     }
    }
Enter fullscreen mode Exit fullscreen mode

Next, create a folder named shop inside the pages folder. Inside the shop folder, create two files, index.js and [id].js. index.js is the page that will have all our products, while [id].js is a dynamic page route that will show the details of a particular product. Inside the index.js file, add the following;

    import Head from "next/head";
    import { ProductCard } from "../../components/ProductCard";
    import styles from '../../styles/Shop.module.css';
    import { XataClient } from '../../src/xata';
    const Shop = ({ products }) => {
     return (
      <>
       <Head>
        <title>Kwetu Furniture | Shop</title>
       </Head>
       <div className={styles.container}>
        {products.map((product) => (
         <ProductCard
          product={product}
          key={product.id}
          id={product.id}
          url={product.image_url}
          name={product.name}
          price={product.price}
          categoryName={product.category.name}
         />
        ))}
       </div>
      </>
     )
    }
    const xata = new XataClient();
    export const getServerSideProps = async () => {
     const response = await xata.db.product.select(["*", "category.*"]).getAll();
     return {
      props: {
       products: response
      }
     }
    };

    export default Shop;
Enter fullscreen mode Exit fullscreen mode

At the top of the file, we are importing the XataClient.

import { XataClient } from '../../src/xata';
Enter fullscreen mode Exit fullscreen mode

Later, we make an instance of the XataClient and assign it to a variable xata.

const xata = new XataClient();
Enter fullscreen mode Exit fullscreen mode

We then use the getServerSideProps function to fetch the products from our database.

const response = await xata.db.product.select(["*", "category.*"]).getAll();
Enter fullscreen mode Exit fullscreen mode

The code snippet above selects all columns in our product tables and all columns in the product_category table. getAll() method is one of the methods that you can use to query your data. It returns an array with all the items in our product table. If your table contains a lot of data, it is advisable to use other methods, such as getMany() or getPaginated(), which return subsets of data.

The getServerSideProps function stores the results in a variable, response, and return props. Props is an object with a key of products, and the value is response. The Shop component receives products as props. We then map through products and return a component, ProductCard, that displays the products. In the styles folder, create a Shop.module.css file and add the following styles.

    .container {
     width: '100%';
     margin: 0 auto;
     display: grid;
     grid-template-columns: 1fr 1fr 1fr 1fr;
     gap: 16px;
    }
    @media only screen and (max-width: 992px) {
     .container {
      grid-template-columns: 1fr 1fr 1fr;
     }
    }
    @media only screen and (max-width: 768px) {
     .container {
      grid-template-columns: 1fr 1fr;
     }
    }
    @media only screen and (max-width: 368px) {
     .container {
      grid-template-columns: 1fr;
     }
    }
Enter fullscreen mode Exit fullscreen mode

[id].js will have more details about the product, such as the product description. Add the code below to the file.

    import { XataClient } from "../../src/xata";
    import Link from "next/link";
    import Image from 'next/image';
    import styles from '../../styles/ProductDetails.module.css';
    import { useDispatch } from 'react-redux';
    import { addToCart } from '../../redux/cartSlice';

    const ProductDetails = ({ item }) => {
     const dispatch = useDispatch();
     return (
      <div className={styles.wrapper}>
       <Link href="/shop">
        <a className={styles.link}>Go to shop</a>
       </Link>
       <div className={styles.container}>
        <Image
         src={item.image_url}
         width={400}
         height={450}
        />
        <div className={styles.details}>
         <p><span className={styles.title}>Name: </span>{item.name}</p>
         <p><span className={styles.title}>Description: </span>{item.description}</p>
         <p className={styles.price}><span className={styles.title}>Price: </span>${item.price}</p>
         <p className={styles.category}><span className={styles.title}>Category: </span>{item.category.name}</p>
         <div className={styles.button}>
          <button
           onClick={() => dispatch(addToCart(item))}
          >
           Add to cart
          </button>
         </div>
        </div>
       </div>
      </div>
     )
    }
    const xata = new XataClient();
    export const getServerSideProps = async(context) => {
     const products = await xata.db.product.select(["*", "category.*"]).getAll();
     const item = products.find((product) => product.id === context.params.id)
     return {
      props: {
       item
      }
     };
    };
    export default ProductDetails;
Enter fullscreen mode Exit fullscreen mode

Create a ProductDetails.module.css file and add the following styles.

    .title {
     font-weight: 600;
    }
    .container {
     max-width: fit-content;
     margin: 0 auto;
     display: flex;
     flex-wrap: wrap;
     gap: 18px;
     padding: 15px;
    }
    .link {
     color: #E24E1B;
     text-decoration: none;
    }
    .link:hover {
     color: #9b2b06;
    }
    .button {
     margin-top: 20px;
    }
    .button button{
     width: '60px';
     background-color: #E24E1B;
     color: white;
     padding: 10px 12px;
     border-radius: 6px;
     border: 1px solid #E24E1B;
    }
    .button button:hover {
     background-color: white;
     color: #E24E1B;
     cursor: pointer;
     border: 1px solid #E24E1B;
    }
    @media only screen and (max-width: 768px) {
     .container {
      flex-direction: column;
     }
    }
Enter fullscreen mode Exit fullscreen mode

Lastly, we will create the Cart Page. Go to the pages folder and create a file cart.js.

    import { useDispatch, useSelector } from "react-redux";
    import styles from '../styles/Cart.module.css';
    import Image from "next/image";
    import { incrementQuantity, decrementQuantity, removeFromCart } from "../redux/cartSlice";

    const Cart = () => {
     const cartList = useSelector((state) => state.cart);
     const dispatch = useDispatch();
     const totalPrice = () => {
      return cartList.reduce(
       (accumulator, item) => accumulator + item.quantity * item.price,
       0
      );
     };
     return (
      <div className={styles.container}>
       {cartList?.length === 0 ? (
        <h4>Cart is empty</h4>
       ) : (
        <>
         <div className={styles.header}>
          <div>Image</div>
          <div>Product Name</div>
          <div>Price</div>
          <div>Quantity</div>
          <div>Actions</div>
          <div>Total Price</div>
         </div>
         {cartList?.map((item) => (
          <div className={styles.body} key={item.id}>
           <div className={styles.image}>
            <Image src={item.image_url} height="90" width="65" />
           </div>
           <p>{item.name}</p>
           <p>$ {item.price}</p>
           <p>{item.quantity}</p>
           <div className={styles.buttons}>
            <button onClick={() => dispatch(incrementQuantity(item))}>
             +
            </button>
            <button onClick={() => dispatch(decrementQuantity(item))}>
             -
            </button>
            <button onClick={() => dispatch(removeFromCart(item))}>
             x
            </button>
           </div>
           <p>$ {item.quantity * item.price}</p>
          </div>
         ))}
         <h2>Grand Total: $ {totalPrice()}</h2>
        </>
       )}
      </div>
     )
    }
    export default Cart;
Enter fullscreen mode Exit fullscreen mode

In the Cart component, we access the state of our application using the useSelector hook and store the array of products in a variable, cartList. We then use the reduce() function to calculate the total price of the products in the cart. In the JSX, we check if the length of cartList is equal to zero. If so, we return ‘Cart is empty’, if not, we map through the array and return the list of products. Each product has increment, decrement, and remove buttons.

Conclusion

This article has covered how to set up and use Xata and Cloudinary in your Next.js e-commerce website and how to use Redux Toolkit, and Redux persist for state management. You can find more information about the different methods to query your data and additional features, such as authentication using Xata, in the Xata documentation.

Below are screenshots of the final web application.

Home page

Home page

Shop

Shop

Product Details

Product Details

Cart

Cart

Here are the links to the deployed application and the Github repositiory.

Top comments (0)