Creating a Full Stack Ecommerce Application from the Ground Up: My Experience

Creating a Full Stack Ecommerce Application from the Ground Up: My Experience

My reflections on creating an E2E full-stack e-commerce app using React, Redux, Node.js, Express, Prisma, and Stripe

ยท

14 min read

Introduction

Typically, my blog posts show fellow developers how to build specific applications while learning a particular feature or a set of features. So far, I have led you through the development of various applications, ranging from comprehensive systems like a full-stack authentication application to beginner projects such as developing a notes application, which helps newcomers grasp the fundamentals of React. While this is fantastic, the issue is that these blog posts primarily serve to reinforce my own learning. Sometimes, however, I want to reflect on the projects I work on in my free time as I learn to code.

It's always fun to explore how someone designs and creates software: which technologies I choose, the reasoning behind my decisions during project development, how I test the program, and so much more.

I believe it will be helpful for others if I share my experiences with the projects I have built. Why? Because I believe every developer recognizes that programming is far more than just typing seemingly gibberish text to accomplish tasks. The process of creating software is a true art, requiring mastery of numerous aspects to be successful. How do you design your system? What technologies do you use? How do you test and scale it? In such a complex problem domain, I believe everyone has something new to learn.

Today, I will be discussing one of my recent projects: a full-stack e-commerce application that uses advanced modern technologies, which I will break down step by step. I hope this lengthy but necessary introduction gets you excited as we begin!

Note: if you want to access the full code or even try the project out for yourself, feel free to head over to my GitHub repo. You'll find both the source code and a link to the deployed project there.

System Design

Now, let's say I've already figured out what I want to build (which isn't too hard when you're working on projects for learning or when your company hands you a task). The first question we need to ask ourselves is, "How am I going to build it?"

The first option is to dive right into coding and just build! However, what if you choose an ill-suited tech stack for your specific task? You would need to redo a significant amount of work from scratch. That's why I prefer to plan my project thoroughly before actually starting.

How do I do that personally? Well, I'm not a system designer by any means, but I have a process that I typically follow, which effectively prevents me from becoming overwhelmed by multiple project features during the development stage.

You can use any tool you prefer, as it doesn't truly matter: from a pen and a piece of paper to sophisticated system design software. Personally, I create system designs in Excalidraw because it offers all the features I need, presents the information neatly, and includes a library of pre-made "components" that can be used to develop the system.

When I develop any software, I outline those important features:

  • Functional requirements: a set of features that describe what the system is supposed to do (system functionality)

  • Non-functional requirements: characteristics of the system (technical specifications) that determine how the system should behave

  • Database design: selecting the database and defining the necessary database models.

  • Use Cases: essential APIs that showcase the interactions between the client and the server

    Notes: a collection of notes taken during the system design process, which should be considered while developing the system

I did not initially think I would be sharing my system design, so I actually deleted my original sketch for this project ๐Ÿซ . However, I have put together a rough example for you that doesn't cover all the use cases, but it should still give you a good starting point.

Note that some things may change along the way, and that's normal, as it's quite challenging to consider everything before you start coding (especially in my case, as this is more of a learning experience for me, and my knowledge and perspectives evolve as I progress).

The purpose of even such a basic system design is not to try replicating Amazon, but rather to avoid getting overwhelmed with numerous features during development. When everything is well-organized in your mind before diving into the code, it makes the process much easier!

Now that we've outlined our simple system design, let's jump into the fun part - developing the program itself! ๐Ÿš€

Managing Products

One of the key features in any online retail shop is, undoubtedly, products. We need to provide a smooth experience for users by displaying products, adding them to the cart, searching for products, and so on.

Typically, I prefer starting with the backend. It's worth noting that I used Node.js and Express in my project for backend development. I divide my backend folder into multiple directories and keep everything separate: controllers, routes, middleware, etc. You can see this if you check out my GitHub repo for this project. For this project, I chose MySQL as the database and used Prisma as the ORM, because it offered a really nice developer experience and was easy to set up and use.

First of all, I began the backend by defining the database schemas. I defined the schemas for products and categories, as well as established a relationship between them. In Prisma, we need to use a special syntax and define the schemas in a file with a ".prisma" extension.

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["fullTextSearch", "fullTextIndex"]
}

datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
}

model Product {
  id            String    @unique
  name          String
  description   String    @db.Text
  price         Float
  stockQuantity Int
  image         String
  category      Category? @relation(fields: [categoryId], references: [id], onUpdate: Cascade)
  categoryId    Int?
  priceId       String    @unique
  createdAt     DateTime  @default(now())

  @@fulltext([name])
  @@fulltext([name, description])
}

model Category {
  id        Int       @id @default(autoincrement())
  name      String    @unique
  createdAt DateTime  @default(now())
  products  Product[]
}

After that, everything becomes quite easy! I defined simple CRUD controllers that allow me to manage the products. There's a small tweak when you start integrating this with Stripe, but more on that later.

Alright, then I dove right into creating the frontend part. My tech stack for the frontend is simply React and Vite for bootstrapping the React app. After initializing the frontend, I began by creating the components that would display either the list of all products or a single product, as well as developing the functionality for filtering and sorting.

Essentially, as you can see, when working on a full-stack project and want to add some feature, I follow this process: define the necessary database schemas; create the backend for the given functionality to understand what to expect on the client side; implement the frontend, and obtain server data by sending API requests.

As a tool for managing fetched data, my choice was clear - React Query. Personally, I believe it is the best React tool for managing asynchronous data: there's no boilerplate code - it gets straight to the point, and it is customizable. For example, whenever a query or mutation error occurs, I don't need to catch the error and display it in every part of my code where I use either of those. Instead, I can set the QueryClient up once to, for instance, display a popup to the user whenever an error occurs - and it will work for all queries and/or mutations! Moreover, it has fantastic devtools!

The other option I considered was Redux Toolkit Query, as I needed Redux for this project anyway. However, the issue I encountered with RTK Query was that it demanded more code for setup while providing the same (if not fewer) benefits compared to React Query. Both are great tools, but this time I chose React Query.

Here is a brief demo of how my product management functionality turned out:

As you can see, the data for products is being fetched, and users can filter, sort, and search through the list of products. One crucial aspect of our project is to allow users to add products to their cart and their list of favorites. To achieve this, I used Redux, as it enables easy access and management of the cart and favorites state. After defining the necessary slices and reducers, I can easily update the cart's state by adding and removing products to and from it.

A minor issue with Redux is that the data will be deleted upon page refresh. To maintain data persistence, I used Redux Persist to store the state in local storage. Therefore, if a user navigates away from the page, the contents of their cart and favorites will be retained.

Implementing Authentication

If you have read my series on authentication, you may know that creating your own authentication system can be overhead. To avoid this, I decided to use the Firebase Auth with support for signing in with either your credentials or a Google account. You may encounter a few difficulties while trying to use Firebase, but they provide detailed documentation, and there are numerous videos explaining what to do. Regardless, I will attempt to cover the main steps of making it work.

After initializing my Firebase application, I set up the authentication context, which was responsible for providing me with access to the current user and their role, as well as functions that manage the authentication process: sign in, sign out, and sign up. I placed this within the context to ensure easy access from anywhere throughout my application.

import { createContext, useContext, useEffect, useState } from "react";
import { auth, googleProvider } from "../app/firebase";
import { User, signInWithEmailAndPassword, UserCredential, signInWithCustomToken, signInWithPopup } from "firebase/auth";
import { useRegisterMutation } from "../features/auth";

// Creating the context
const AuthContext = createContext<IAuthContext>({
    currentUser: null,
    token: "",
    isAdmin: undefined,
    signIn: () => null,
    signInWithToken: () => null,
    signInWithGoogle: () => null,
    signUp: () => null,
    signOut: () => undefined
});

export const useAuth = () => {
    return useContext(AuthContext);
};

export const AuthProvider = ({ children } : Props) => {
    // Currently signed-in user
    const [currentUser, setCurrentUser] = useState<User | null>(null);
    // Token of the currently signed-in user
    const [token, setToken] = useState<string>("");
    const [isLoading, setIsLoading] = useState<boolean>(true);
    // Role of the signed-in user (admin or user)
    const [isAdmin, setIsAdmin] = useState<boolean>();

    // Mutation to register the user
    const { mutateAsync: register } = useRegisterMutation();

    // Update the current user, token, and role
    const handleCurrentUser = async (user: User | null) => {
        if (user) {
            setCurrentUser(user);
            const decoded = await user.getIdTokenResult();
            setToken(decoded.token);
            setIsAdmin(decoded.claims.role === "ADMIN");
        } else {
            setCurrentUser(null);
            setToken("");
        }
        setIsLoading(false);
    };

    // Sign in with the email and password
    const signIn = async (email: string, password: string) => {
        return await signInWithEmailAndPassword(auth, email, password);
    };
    // Sign in with custom token
    const signInWithToken = async (token: string) => {
        return await signInWithCustomToken(auth, token);
    };
    // Sign in with Google account
    const signInWithGoogle = async () => {
        return await signInWithPopup(auth, googleProvider);
    };
    // Sign up (not handled by Firebase Auth)
    const signUp = async (data: IRegisterCredentials) => {
        // After successful sign up, retrieving the custom token
        // And immediately signing the user in 
        const { token } = await register(data);
        return await signInWithToken(token);
    };

    // Sign out
    const signOut = () => {
        auth.signOut();
    };

    useEffect(() => {
        // Observer to handle the change of auth state
        const unsubscribe = auth.onAuthStateChanged(handleCurrentUser);
        return () => unsubscribe();
    }, []);

    const value = {
        currentUser,
        isAdmin,
        token,
        signIn,
        signInWithToken,
        signInWithGoogle,
        signUp,
        signOut
    };

    return (
        <AuthContext.Provider value={value}>
            { !isLoading && children }
        </AuthContext.Provider>
    );
};

I removed a few type definitions from this code to focus on the main aspects, but remember that you can access the full code through my GitHub repository. As you can see, the context manages authentication and stores the user's token, information about the currently signed-in user, and their role. We also used the useEffect hook so that every time the authentication state changes, such as when the user signs out, the current user gets updated.

With this setup, I can easily access all the authentication functionality whenever I need it on my frontend. For instance, here is a component I created to protect specific routes against unauthorized users:

import { Navigate, useLocation, Outlet } from "react-router-dom";
import { useAuth } from "../context/AuthContext";

export const ProtectedRoute = () => {
    // Accessing the auth context
    const { currentUser } = useAuth();
    const location = useLocation();

    // If the user is provided, redirect to the requested page
    // Otherwise, redirect to the login page
    return currentUser ? (
        <Outlet />
    ) : (
        <Navigate to="/auth/login" state={{ from: location }} replace />
    );
};

However, I was looking to have more control over the authentication process since I also wanted to protect the API routes. I did not want any unauthorized users to get their hands on protected information. That is why I decided to use Firebase Admin SDK on the backend to create my very own custom token.

Firstly, I defined new database schema in Prisma that corresponds to storing the information about the user:

model User {
  firebaseId String  @unique
  email      String  @unique @db.VarChar(255)
  fullName   String
  role       Role    @default(USER)
  avatar     String?
}

enum Role {
  USER
  ADMIN
}

You may notice that, instead of generating an arbitrary ID for the user, I used the unique ID automatically generated by Firebase, which I named firebaseId. This is quite convenient, as it allowed me to avoid storing two IDs for a single user.

After setting up the Firebase Admin SDK on the backend, I created the following middleware for authorization:

import { Request, Response, NextFunction } from "express";
import { auth } from "../config/firebase";

export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
    const authHeader = req.headers.authorization;
    if (!authHeader) {
        return res.status(401).json({ message: "Authorization is required" });
    }
    const accessToken = authHeader.split(" ")[1];
    if (!accessToken) {
        return res.status(401).json({ message: "Authorization is required" });
    }

    try {
        // Verifying the provided access token
        const decodedToken = await auth.verifyIdToken(accessToken);
        // Attaching the ID of the user and their role to the request
        req.uid = decodedToken.uid;
        req.role = decodedToken.role;
        next();
    } catch (error) {
        res.status(401).json({ message: "Unauthorized"});
    }
};

Inside the middleware, I checked if the request contained a custom authorization token, then decoded this token to retrieve the user's information, specifically their ID and role. One last important aspect of authentication to mention is how I handle creating a new user:

import { NextFunction, Request, Response } from "express";
import { auth } from "../config/firebase";
import prisma from "../config/prisma-client";

export const register = async (req: Request, res: Response, next: NextFunction) => {
    try {
        // Creating the user with Firebase
        const userFirebase = await auth.createUser({
            email: req.body.email,
            password: req.body.password,
            displayName: req.body.fullName
        });

        const role = req.body.isAdmin ? "ADMIN" : "USER";
        // Saving the user data in database
        await prisma.user.create({
            data: {
                firebaseId: userFirebase.uid,
                email: req.body.email,
                fullName: req.body.fullName,
                role
            }
        });

        // Issuing access token with newly assigned ID
        const token = await auth.createCustomToken(userFirebase.uid);
        // Attching the role of the user as custom claim
        await auth.setCustomUserClaims(userFirebase.uid, { role });

        res.status(200).json({ token });
    } catch (error) {
        next({ message: "Unable to sign up the user with given credentials", error });
    }
};

First, I initialized the new user through the Firebase Admin SDK by providing their email and password, allowing Firebase to authenticate this user later. Although the new user was created within the Firebase system, I also needed to store their details in the database. That's why I subsequently stored it using Prisma.

Afterward, I issued a new access token, set up the user with the necessary role (either admin or user), and sent the access token back. This way, after signing up, the user can immediately sign in with the provided token without having to enter their details again on the login page.

So, we've covered authentication, which is great ๐Ÿ”. But let's not get too sidetracked for now, because the next thing you might be wondering is, "Alright, I can authenticate my users, but how do I manage them?"

Managing Users

To be honest, since this is an online store and all I needed from users were their shipping details, I decided to make user management as hassle-free for them as possible. That's why I only stored basic information that might be needed: email and password for authentication, as well as their full name. That's it! :) You'll see later that Stripe fully handled storing the user's address. That's why I decided not to store it in the database, but simply hand it over to Stripe to manage.

Having previously defined the database schema to store user details, my next task was to create basic controllers on the backend to handle retrieving data about specific users using their IDs and updating user information when necessary. Following this, I created a page to allow users to manage their information, and this is how it ultimately appeared:

Conclusions

I tried to keep things concise and briefly touch upon all the topics, but this blog post became quite lengthy, so I decided to divide it into two parts. There are still some interesting topics left to discuss: processing payments and handling orders with Stripe, uploading images to Cloudinary, and testing the application using Cypress. ๐ŸŒŸ

If you enjoyed this blog post and would like to see more like it, just let me know! I hope that this article either taught you something new or reinforced your existing knowledge. And who knows, maybe it even inspired fellow learners like me to create their own e-commerce project! Regardless, if you have any questions or need a helping hand, don't be shy to reach out. Feel free to contact me, even if it's not related to this article.

If you enjoyed this article, remember to follow me on Twitter/X, where I share daily updates. And feel free to connect with me on LinkedIn.

ย