PENN Stack Mastery: Building a full stack project with Postgres, Express, Node.js, Next.js, and TypeScript

PENN Stack Mastery: Building a full stack project with Postgres, Express, Node.js, Next.js, and TypeScript

Hello everyone, today we will build a fullstack app from scratch including the frontend and backend, using PENN stack, i.e., PostgreSQL, Express.js, Node.js, and Next.js. For better experience we will use TypeScript over JavaScript.

We will use PostgreSQL as database, Prisma as ORM, Express.js to create our server, Next.js along with Tailwind CSS for our frontend.

The idea of the project is simple, we will create a simple note taking app with features such as user sign up and login, ability to create a new note, delete and edit a particular note. Lets call the project MemoEase.

Initial Setup

Here's how you can get started with the setup:

  1. Create a new folder on your local PC.

  2. Open GitHub Desktop, and click on Add Existing Repository...

  3. Choose the folder that you created initially, it will ask you to create a repository instead. Do the same, by putting in the required information

  4. After that open that folder in VSCode or any other IDE of your choice.

  5. Create 2 folders, namely: client and server.

  6. The initial setup is all done for now.

Backend

First we will create the server for our application and for that we will be using Express.js along with TypeScript.

Follow the following steps to setup the server:

  1. Open your VSCode terminal and type the following command: cd server to get to the server folder, then type npm init --y, to create a node.js project.

  2. Next, we need to install some dependencies. Type the following commands one by one to install all the required dependencies:

    1. npm i express

    2. npm i nodemon

    3. npm i prisma @prisma/client

  3. Next create a new folder called prisma and inside it create a file schema.prisma in it.

  4. In this file, we will create schema for our database. First write the following code in this file:

generator client {
  provider = "prisma-client-js"
}

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

The DATABASE_URL stores the actual url of our database, and for security reasons, we will store it in a .env file.

For the database url, you first need to install PostgreSQL and pgAdmin on your machine. And then, create a new Database and call it memo-ease or anything else that you want.

The database URL would look something like: postgresql://postgres:swapn@localhost:5432/memo-ease

  1. Next let's write our schema for user and notes.

     generator client {
       provider = "prisma-client-js"
     }
    
     datasource db {
       provider = "postgresql"
       url      = env("DATABASE_URL")
     }
    
     model User {
       id          String   @id @default(uuid())
       username    String   @unique
       password    String
       email       String   @unique
       createdAt   DateTime @default(now())
       updatedAt   DateTime @updatedAt
       notes       Note[]
     }
    
     model Note {
       id          String    @id @default(uuid())
       title       String
       description String
       userId      String
       user        User      @relation(fields: [userId], references: [id])
       createdAt   DateTime  @default(now())
       updatedAt   DateTime  @updatedAt
     }
    

    User will have an id, username, password, email, createdAt, updatedAt and Notes. Note will have an id, title, description, userId, user (this will basically be a relation between a user and Notes, as a single user can have multiple notes), createdAt and updatedAt.

    The idea is that we will have multiple users and each user will have zero or more notes that he/she can view, edit or delete.

  2. Next, we need to migrate our code to make our Prisma schema in sync with our actual database schema. To do the same, run the following command: prisma migrate dev --name init.

    You can read more about Prisma and Prisma migration from their official docs:

    https://www.prisma.io/docs/orm/prisma-migrate/getting-started

  3. Upon successful migration, you will see a new folder called migrations being created inside the prisma folder as follows:

  1. Next, our job is to create our express app and write all the required routes, but before that we need to install TypeScript. Run the following commands to do the setup:

    npm install typescript --save-dev

    npx tsc --init

    You will find a tsconfig.json file has been created. Make sure the file has following content:

     //tsconfig.json
     {
       "compilerOptions": {
         "target": "es2016",
         "module": "commonjs",
         "esModuleInterop": true,
         "forceConsistentCasingInFileNames": true,
         "strict": true,
         "skipLibCheck": true,
         "outDir": "./build",  // Use "./build" for clarity
         "types": ["node", "express", "./custom.d.ts"]
       },
       "include": ["src/**/*"]
     }
    
  2. Next create a folder src and a file called app.ts in it. First install cors by typing the following command: npm i cors on terminal.

    Cross-Origin Resource Sharing (CORS) is a security feature implemented by web browsers to control how web pages in one domain can request and interact with resources on another domain. You can read more about it online. To avoid CORS error we need to use this cors package.

  3. Next, let's setup our Express app

     import express, {Application, Request, Response} from 'express';
    
     const PORT: number = 8080;
     const app: Application = express();
    
     app.use(express.json());
     app.use(cors());
    
     app.get("/", (req: Request, res: Response): void => {
         res.send("hello world");
     });
    
     app.listen(PORT, (): void => {
         console.log(`Server is up and running on PORT: ${PORT}`);
     });
    

    After this go to your package.json and inside "scripts", create a key value pair:

    "start": "tsc --watch & nodemon ./build/app.js"

  4. Then type npm run start in the terminal and go to your browser, search localhost:8080 and you will find a page like this:

    This means your server is up and running.

  5. Congrats you are done with the setup of your express.js app, now our main job starts, i.e., to write all the required API routes for our application.

Routes for User

  1. First let's create a route to register a new user to the database.

  2. The idea is simple, we will take username, email and password from the user and simply store it in our database.

    But for that first we need to import PrismaClient to access our database and perform the required operations.

    Also you need to install bcrypt to store password in hashed format in the database. For safety purposes, it's recommended to store password in hashed format just in case data is leaked, hacker won't have access to passwords of user. To install bcrypt type the following command in terminal: npm i bcrypt.

     const bcrypt = require('bcrypt');
    
     //route to register a new user
     app.post("/register", async (req: Request, res: Response): Promise<void> => {
         const { username, email, password }: { username: string; email: string; password: string } = req.body;
    
         try {
             // Hash the password
             const hashedPassword = await bcrypt.hash(password, 10);
    
             const newUser = await prisma.user.create({
                 data: {
                     username,
                     email,
                     password: hashedPassword,
                 },
             });
    
             res.json(newUser);
         } catch (err) {
             console.error("Error during registration:", err);
             res.status(500).json({ error: "Internal Server Error" });
         }
    
  3. Now, to test this route you can simply go to postman, create a new post request to this route, with the data in body in the following format:

  4. We are done with the Sign Up route. Now, it's time to create a route to login the user to the database. The idea is simple, we will ask user to input his/her username and password, then we will try to look up for that username in our database, if it exists, match the password that user entered with the password in the database, if the password matches then generate a JWT token and return it, implying successful login.

    JWT (JSON Web Token) is a compact, URL-safe means of representing claims between two parties. It's often used for user authentication and authorization in web development. JWTs consist of three parts: a header, a payload, and a signature. They're commonly signed to ensure integrity, and their self-contained nature makes them efficient for transmitting information between the client and server securely. You can read more about it online.

    To do this, first you need to install a package jsonwebtoken by typing the command npm i jsonwebtoken.

    You also need to install another package called crypto, that will be used to generate a secret key for generating the JWT token.

     import jwt from 'jsonwebtoken';
     const crypto = require('crypto');
    
     const generateSecretKey = () => {
       return crypto.randomBytes(32).toString('hex');
     };
    
     const SECRET_KEY = generateSecretKey();
    
     //route to login
     app.post('/login', async (req: Request, res: Response): Promise<void> => {
         const { username, password }: { username: string; password: string } = req.body;
    
         try {
           const user = await prisma.user.findUnique({ where: { username } });
    
           if (!user || !(await bcrypt.compare(password, user.password))) {
             res.status(401).json({ error: 'Invalid username or password' });
           }
    
           // Create a JWT with user information
           const token = jwt.sign({ id: user.id, username: user.username, email: user.email }, SECRET_KEY);
    
           res.json({ token });
         } catch (err) {
           console.error('Error during login:', err);
           res.status(500).json({ error: 'Internal Server Error' });
         }
       });
    
  5. Then, you can check this route too in postman and on successful login it will return a JWT token as response.

  6. After this, we need to create a route to fetch the required information of the currently logged in user from this JWT token. Basically, we want such a feature because let's say on the client side, after logging in, we need a way to display the currently logged in user's name on the navbar.

     // Route to get user information based on the JWT token for the currently logged in user
     app.get('/getCurrentLoggedInUser', (req: Request, res: Response): void => {
       const token = req.headers.authorization?.split(' ')[1];
    
       // Authorization: 'Bearer TOKEN'
       if (!token) {
         res.status(200).json({ success: false, message: 'Error! Token was not provided.' });
         return;
       }
    
       try {
         // Decoding the token
         const decodedToken = jwt.verify(token, SECRET_KEY) as { id: string; username: string, email: string };
    
         res.status(200).json({
           success: true,
           data: {
             id: decodedToken.id,
             username: decodedToken.username,
             email: decodedToken.email,
           },
         });
       } catch (error) {
         res.status(200).json({ success: false, message: 'Invalid token.' });
       }
     });
    

    To test this route, go to Postman, go to the Authorization section, choose type as Bearer Token, and type that particular token that was returned before on successful login here.

    And this is how you will get all the required information of the currently logged in user.

  7. And now, we are done with all the routes related to the user. Next, we need to create routes for our notes.

Routes for Notes.

  1. If you remember our schema, the idea was that each user would have a set of notes with him. So, what we basically want is that after a user logs in and a jwt token is generated, we should be somehow able to get the currently logged in user's id and then we will search for this id in the database and get the corresponding notes.

    For this, we will create a middleware.

    Middleware is software that acts as a bridge between different components or layers of an application. In web development, it often intercepts and processes requests before they reach the final destination, allowing for tasks like authentication, logging, or modifying the request/response. Think of it as a helpful assistant that manages communication between different parts of your application. You can read more about it online.

     //middleware to fetch the id of user from JWT token
     const extractUserId = (req: Request, res: Response, next: NextFunction): void => {
       const token = req.headers.authorization?.split(' ')[1];
    
       if (token) {
           try {
               const decoded = jwt.verify(token, SECRET_KEY) as { id: string; username: string, email: string };
    
               if (typeof decoded === 'string') {
                   // In case the token is valid but only contains a string (not a JwtPayload)
                   req.userId = decoded;
               } else {
                   // If the token is a JwtPayload, extract the user ID
                   req.userId = decoded.id;
               }
           } catch (err) {
               // Ignore errors, don't reject the request
           }
       }
    
       next();
     };
    
  2. Now, after this first let's create a route to add a new note to the database. It will take title and description from the body and user id generated from that middleware. Then it will simply create a new note and store it in the database.

     //route to add a new note to the database for the currently logged in user
     app.post("/addNote", extractUserId, async (req: Request, res: Response): Promise<void> => {
       const {title, description} : {title: string, description: string} = req.body;
       const userId = req.userId;
    
       try{
         if(!userId){
           res.status(401).json({error: "Unauthorised"});
           return;
         }
    
         const newNote = await prisma.note.create({
           data: {
             title, 
             description,
             userId
           }
         });
    
         res.json(newNote);
       }catch(err){
         console.log("New err encountered: ", err);
         res.status(500).json({error: "Internal Server Error"});
       }
     });
    

    You can verify this route in Postman by putting the jwt token in the Authorization section and creating a new JSON object with 2 keys: title and description.

  3. Next, we need a route to fetch all the notes for the currently logged in user.

     //route to fetch the notes for currently logged in user
     app.get("/fetchNotes", extractUserId, async(req: Request, res: Response): Promise<void> => {
       const userId = req.userId;
       try {
         if(!userId){
           res.status(401).json({error: "Unauthorised"});
           return;
         }
    
         const userWithNotes = await prisma.user.findUnique({
           where:{
             id: userId
           },
           include: {
             notes: true
           }
         });
    
         if(!userWithNotes){
           res.status(404).json({ error: "User not found" });
           return;
         }
    
         const notes = userWithNotes.notes;
         res.status(200).json({ notes });
       } catch (error) {
         console.log("New error encountered: ", error);
         res.status(500).json({error: "Internal Server Error"});
       }
     });
    
  4. Next, we need a route to edit a particular note. The idea is simple, the route accepts id of a note, searches for that particular note in the database, if found, update it's content.

     // route to edit a particular note
     app.patch("/editNote/:id", extractUserId, async (req: Request, res: Response): Promise<void> => {
       const userId = req.userId;
       const noteId = req.params.id;
       const { title, description }: { title: string; description: string } = req.body;
    
       try {
         // Check if the note exists and belongs to the user
         const existingNote = await prisma.note.findUnique({
           where: {
             id: noteId,
           },
           select: {
             userId: true,
           },
         });
    
         if (!existingNote || existingNote.userId !== userId) {
           res.status(404).json({ error: 'Note not found or unauthorized' });
           return;
         }
    
         // Update the note
         const updatedNote = await prisma.note.update({
           where: {
             id: noteId,
           },
           data: {
             title,
             description,
           },
         });
    
         res.json(updatedNote);
       } catch (error) {
         console.error('Error editing note:', error);
         res.status(500).json({ error: 'Internal Server Error' });
       }
     });
    
  5. Finally, we need a route to delete a particular note based on it's id.

     //route to delete a particular note
     app.delete("/deleteNote/:id", extractUserId, async(req: Request, res: Response): Promise<void> => {
       const userId = req.userId;
       const noteId = req.params.id;
    
       try {
         const existingNote = await prisma.note.findUnique({
           where: {
             id: noteId
           },
           select: {
             userId: true
           }
         });
    
         if(!existingNote || existingNote.userId !== userId){
           res.status(404).json({ error: "Note not found or unauthorised" });
           return;
         }
    
         await prisma.note.delete({
           where:{
             id: noteId
           }
         });
    
         res.json({ success: true, message: "note deleted successfully" });
       } catch (error) {
         console.log("Error encountered: ", error);
         res.status(500).json({ error: "Internal Server Error" })
       }
     });
    

    Now, we are done with all the required routes that we need for our application, so let's move to the frontend.

Frontend

For frontend, I am going to use Next.js along with Tailwind CSS.

First open the terminal and type the command cd client to get to the client folder, then type the command npx create-next-app@latest to set up Next.js, make sure you choose TypeScript and Tailwind CSS while setting up.

  1. First, go to page.jsx in the app folder. This will be the landing page for our application. I want to keep it simple, so I will simply put a basic intro in a box along with a button to go to the /notes page.

    Here's what the code will be like:

     'use client'
     import React, {useEffect} from 'react';
     import { useRouter } from 'next/navigation';
    
     export default function Home() {
       const router = useRouter();
    
       const handleClick = () => {
         router.push("/notes");
       }
    
       return (
         <main className="flex flex-col justify-center items-center h-screen">
         <div className="bg-blue-700 h-[22rem] w-[20rem] p-6 flex flex-col items-center justify-center text-white rounded-xl">
           <h1 className="text-[1.5rem] underline underline-offset-8 mb-[2rem]">
             Welcome to MemoEase!
           </h1>
           <p className="text-center">
             Unlock the power of effortless organization and enhanced productivity with MemoEase. 
             Seamlessly designed for simplicity and efficiency, MemoEase is your digital haven for note-taking and 
             task management.
           </p>
         </div>
         <button className="bg-blue-700 h-[4rem] w-[20rem] mt-[2rem] rounded-xl text-white" onClick={handleClick}>
           View my Notes
         </button>
       </main>
       )
     }
    

    Here's how the page will look like:

    You can change the design accordingly. Just make sure the basic concept is same, i.e, add an option to visit the /notes page.

  2. Then, first let's create a Navbar and a Footer for our application and make it globally available on all the pages of our application.

    For that create a /components folder and create a file called Footer.tsx in it.

     import React from 'react';
    
     export const Footer = () => {
       return (
         <div className="bg-blue-700 w-screen h-[5rem] text-white flex justify-center items-center text-[1.2rem] fixed bottom-0">
             Made with ❤️ by Swapnil Pant
         </div>
       )
     }
    

    I kept the footer as simple as possible, you can change it according to your taste.

  3. Now, first let's create a simple SignUp and a Login page. For this, create a new folder called /signup and then create a new file called page.tsx in it. Next.js makes routing very simple. For every webpage that you want in your application, you can simply create a new folder and the name of that folder would be a new route.

    Here's what you should put in the /signup/page.tsx file:

     'use client'
     import React, { useState } from 'react';
    
     const SignUp = () => {
    
       return (
         <div className = "bg-gray-300 w-screen h-screen flex items-center justify-center">
             <div className = "h-[30rem] w-[24rem] bg-white rounded-2xl flex flex-col justify-center items-center">
                 <div className = "bg-blue-600 w-[15rem] text-white  py-2 flex justify-center text-[1.1rem] rounded-xl">
                     Sign Up
                 </div>
    
                 <form className="mt-[2rem] flex flex-col items-center gap-y-6">
                     <input 
                         placeholder = "Username" 
                         className = "w-[18rem] p-2 rounded-lg border-2 border-gray-500"
                         type = "text"
                         name = "username" 
                     />
                     <input 
                         placeholder = "Email" 
                         type = "email" 
                         name = "email"
                         className = "w-[18rem] p-2 rounded-lg border-2 border-gray-500"
                     />
                     <input 
                         placeholder = "Password" 
                         type = "password"
                         name = "password"
                         className = "w-[18rem] p-2 rounded-lg border-2 border-gray-500"
                     />
    
                     <button className = "bg-blue-600 w-[18rem] h-[3rem] rounded-xl text-white" type = "submit">
                         Submit
                     </button>
                 </form>
    
             </div>
         </div>
       )
     }
    
     export default SignUp;
    

    Here's how the SignUp page looks like:

    You can change the design accordingly.

  4. Next, similarly let's implement the Login page too. Create a new file /login/page.tsx and include the following code in it:

     'use client'
     import React, { useState } from 'react';
    
     const Login = () => {
       return (
         <div className="bg-gray-300 w-screen h-screen flex items-center justify-center">
           <div className="h-[26rem] w-[22rem] bg-white rounded-xl flex flex-col justify-center items-center">
             <div className="bg-blue-600 w-[15rem] text-white py-2 flex justify-center text-[1.1rem] rounded-xl">
               Log In
             </div>
    
             <form className="mt-[2rem] flex flex-col items-center gap-y-6">
               <input
                 placeholder="Username"
                 type="text"
                 name="username"
                 className="w-[18rem] p-2 rounded-lg border-2 border-gray-500"
               />
               <input
                 placeholder="Password"
                 type="password"
                 name="password"
                 className="w-[18rem] p-2 rounded-lg border-2 border-gray-500"
               />
               <button className="bg-blue-600 w-[18rem] h-[3rem] rounded-xl text-white" type="submit">
                 Submit
               </button>
             </form>
           </div>
         </div>
       );
     };
    
     export default Login;
    

    This is how the Login page looks like:

  5. Now, let's add functionality to these pages.

    For this, first let's create a /hooks folder. In this folder, we will create custom hooks. Rather than calling all the APIs that we created earlier again and again, we will create functions for them to avoid code repetition.

    Our first hook would be to implement that /register route that we created earlier to register a new user to the database.

    In the /hooks folder, create a new file called signUp.ts and include the following code in it:

     import axios from "axios";
    
     interface SignUpData {
         username: string;
         email: string;
         password: string;
     }
    
     const signUp = async (userData: SignUpData): Promise<any> => {
         try {
             const response = await axios.post("http://localhost:8080/register", userData);
             return response.data;
         } catch(error: any) {
             throw error.response.data;
         }
     }
    
     export default signUp;
    

    Then go back to that SignUp page that we created earlier and update the code as follows:

    ```typescript 'use client' import React, { useState } from 'react'; import signUp from '../hooks/signUp'; import { ToastContainer, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css';

    const SignUp = () => { const [formData, setFormData] = useState({ username: "", email: "", password: "" });

    const handleChange = (e: any) => { setFormData({ ...formData,

}); }

const handleSubmit = async (e: any) => { e.preventDefault();

try { const user = await signUp(formData); toast.success("Successfully registered..."); } catch(error: any) { toast.error("Registration failed ", error); } }

return (

Sign Up

Submit

) }

export default SignUp;


    Note, how on clicking on the submit button, we are simply calling the hook/function that we created just now and then showing success notification. For the notification we used `react-toastify` library. You can simply install it by typing the following command in the terminal: `npm i react-toastify`.

6. Next, similarly we can create a hook to call the `/login` route that we created earlier and implement it in the login page.

    First, create a new file called `login.ts` in the `/hooks` folder and write the following code in it:

    ```typescript
    import axios from "axios";

    interface LoginData {
        username: string;
        password: string;
    }

    const login = async (userData: LoginData): Promise<any> => {
        try {
            const response = await axios.post("http://localhost:8080/login", userData);
            return response.data;
        } catch(error: any) {
            throw error.response.data;
        }
    }

    export default login;

Next, go to your login page code and update the content as follows:

    'use client'
    import React, { useState } from 'react';
    import login from '../hooks/login';
    import { ToastContainer, toast } from 'react-toastify';
    import 'react-toastify/dist/ReactToastify.css';
    import { useAuth } from '../context/AuthContext';
    import { useRouter } from 'next/navigation';

    const Login = () => {
      const { setToken } = useAuth();
      const router = useRouter();

      const [formData, setFormData] = useState({
        username: '',
        password: '',
      });

      const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        setFormData({
          ...formData,
          [e.target.name]: e.target.value,
        });
      };

      const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();

        try {
          const currToken = await login(formData);
          setToken(currToken.token);
          toast.success('Successful login');

          // Redirect to the Notes page
         // setInterval(() => {
           // router.push('/notes');
          //}, 2000);

        } catch (error: any) {
          toast.error('Failed to login', error);
        }
      };

      return (
        <div className="bg-gray-300 w-screen h-screen flex items-center justify-center">
          <div className="h-[26rem] w-[22rem] bg-white rounded-xl flex flex-col justify-center items-center">
            <div className="bg-blue-600 w-[15rem] text-white py-2 flex justify-center text-[1.1rem] rounded-xl">
              Log In
            </div>

            <form className="mt-[2rem] flex flex-col items-center gap-y-6" onSubmit={handleSubmit}>
              <input
                placeholder="Username"
                type="text"
                name="username"
                value={formData.username}
                onChange={handleChange}
                className="w-[18rem] p-2 rounded-lg border-2 border-gray-500"
              />
              <input
                placeholder="Password"
                type="password"
                name="password"
                value={formData.password}
                onChange={handleChange}
                className="w-[18rem] p-2 rounded-lg border-2 border-gray-500"
              />
              <button className="bg-blue-600 w-[18rem] h-[3rem] rounded-xl text-white" type="submit">
                Submit
              </button>
            </form>
          </div>

          <ToastContainer />
        </div>
      );
    };

    export default Login;

This code won't work as of now. We need to create a global context for the token variable. For this check the next step.

  1. Now, we need to create the Navbar before creating our Navbar, we need to do some things. My idea of Navbar is simple, I will show the name of the project on the left most corner and options to SignUp and Login on the right most corner, on successful login it will display the currently logged in user's username along with an option to logout.

    First, we need to create a global context to store the token generated on successful login globally so that every component and page of our application can easily access that JWT token.

    For this create a new folder /context with a file AuthContext.tsx in it. And then include the following code in it:

     'use client'
     import { createContext, useContext, ReactNode, useEffect, useState } from 'react';
     import { setCookie, parseCookies } from 'nookies'; // Install 'nookies' using npm or yarn
    
     interface AuthContextProps {
       token: string | null;
       setToken: (token: string | null) => void;
     }
    
     const AuthContext = createContext<AuthContextProps | undefined>(undefined);
    
     export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
       const initialToken = parseCookies().token || null;
       const [token, setToken] = useState<string | null>(initialToken);
    
       useEffect(() => {
         // Update the cookie whenever the token changes
         setCookie(null, 'token', token || '', {
           maxAge: 30 * 24 * 60 * 60, // 30 days
           path: '/',
         });
       }, [token]);
    
       return (
         <AuthContext.Provider value={{ token, setToken }}>
           {children}
         </AuthContext.Provider>
       );
     };
    
     export const useAuth = () => {
       const context = useContext(AuthContext);
       if (!context) throw new Error('useAuth must be used within an AuthProvider');
       return context;
     };
    

    Note that also need a way to make the token available somehow on the client side for the time the user is using the application. The simple idea is to store the token in cookies. You can store it in localStorage or sessionStorage too but it's not recommended as it's not a safe practice and vulnerable to attacks.

    For storing the token in cookies I made use of a package called nookies that you can simply install by typing the command npm i nookies.

    After this go to the file layout.tsx, which is the entry point of your Next.js application and update the code as follows:

     import type { Metadata } from 'next'
     import { Inter } from 'next/font/google'
     import './globals.css'
     import { Navbar } from './components/Navbar'
     import { Footer } from './components/Footer'
     import { AuthProvider } from './context/AuthContext'
    
     const inter = Inter({ subsets: ['latin'] })
    
     export const metadata: Metadata = {
       title: 'Create Next App',
       description: 'Generated by create next app',
     }
    
     export default function RootLayout({
       children,
     }: {
       children: React.ReactNode
     }) {u
       return (
         <AuthProvider>
           <html lang="en">
             <head>
               {/* Include head elements here */}
             </head>
             <body className={inter.className}>
               <Navbar />
               {children}
               <Footer />
             </body>
           </html>
         </AuthProvider>
       )
     }
    

    We are basically wrapping our entire project in our AuthProvider to access the token and the function setToken globally. Also, we include the Navbar and Footer components in this file too so that they are visible in every single page.

    Now, we will create a hook to fetch the information of the currently logged in user.

    Go to the /hooks folder and create a new file called getCurrentLoggedInUser.ts and implement the required route in it.

     import axios from "axios";
     import { useEffect, useState } from "react";
     import { useAuth } from "../context/AuthContext";
    
     interface UserData {
       id: string;
       username: string;
       email: string;
       // Add other properties if needed
     }
    
     interface ApiResponse {
       success: boolean;
       data: UserData;
     }
    
     const getCurrentLoggedInUserData = () => {
       const { token } = useAuth();
       const [userData, setUserData] = useState<UserData | null>(null);
       const [loading, setLoading] = useState(true);
       const [error, setError] = useState<string | null>(null);
    
       useEffect(() => {
         const fetchData = async () => {
           try {
             if (!token) {
               throw new Error("Token not found");
             }
    
             const response = await axios.get<ApiResponse>("http://localhost:8080/getCurrentLoggedInUser", {
               headers: {
                 Authorization: `Bearer ${token}`,
               },
             });
    
             setUserData(response.data.data);
           } catch (error: any) {
             setError(error.response?.data || error.message);
           } finally {
             setLoading(false);
           }
         };
    
         fetchData();
       }, [token]);
    
       return { userData, loading, error };
     };
    
     export default getCurrentLoggedInUserData;
    

    Note, how we are fetching the token from the global context and then simply using it to fetch the required response. For the UserData, we have created an interface, as we know what the UserData would contain. Similarly, we also created an interface for our ApiResponse to avoid any issues later. This is the reason why we preferred using TypeScript over JavaScript.

    Now, we are all done. Next, simply go to the /components folder and create a new file called Navbar.tsx in it and include the following code in it:

     'use client'
     import React, { useState, useEffect } from 'react';
     import getCurrentLoggedInUserData from '../hooks/getCurrentLoggedInUserData';
     import { useAuth } from '../context/AuthContext';
     import { destroyCookie } from 'nookies';
    
     export const Navbar = () => {
       const { token, setToken } = useAuth();
       const { userData, loading, error } = getCurrentLoggedInUserData();
    
       useEffect(() => {
         console.log("usedata: ", userData);
       }, [userData])
    
       const [isMounted, setIsMounted] = useState(false);
    
       useEffect(() => {
         setIsMounted(true);
       }, []);
    
       if (!isMounted) {
         return null;
       }
    
       const handleLogout = () => {
         destroyCookie(null, 'token', { path: '/' });
         setToken(null);
       }
    
       return (
         <div className="w-full bg-blue-700 text-white h-[6rem] flex flex-row items-center justify-between px-[2rem] fixed top-0 z-[10]">
           <a href="/"><h1 className="text-[2rem] cursor-pointer">MemoEase</h1></a>
           {token ? (
             <div className="flex flex-row gap-x-2 items-center">
               <img src="./user.png" alt="user_icon" className="w-[2.5rem] h-[2.5rem]" />
               <div className = "flex flex-col">
                 <h1 className="text-[1.1rem]">{userData?.username}</h1>
                 <h1 className="text-pink-200 underline underline-offset-2 cursor-pointer" onClick={handleLogout}>Logout</h1>
               </div>
             </div>
           ) : (
             <div className="flex flex-row items-center justify-center text-white gap-x-4">
               <a href="/login">Login</a>
               <a href="/signup">Sign Up</a>
             </div>
           )}
         </div>
       );
     };
    

    The idea of the Navbar is simple as I already told you before name of the project on left hand side and the option to Login and Sign Up on right hand side. If token exists, that means someone is already signed in. Just use the hook we created recently to fetch the username of the currently logged in user and display it in place of Login and Sign Up options. Also show an option to logout. Upon clicking logout, the JWT token is deleted from the cookies.

  2. At this point your application is half done and you can simply check out if the Sign Up and Login features are working fine.

    Try to go to the /signup page and check if it's working by typing in some data.

    Upon clicking Submit, you will get a notification Successfully registered...

    Next, try to login using the same credentials.

    You will notice how the username appears on the top along with the option to Logout.

    You can also go to Application and notice that there would be a new cookie there with the key named as token.

    Congratulations, you are done with implementing the SignUp and Login routes on the client side.

  3. Next, let's start working on the Notes part. First create a new hook fetchNotes.ts to implement the required hook to fetch our notes.

    ```typescript import axios from "axios"; import { useState, useEffect } from "react"; import { useAuth } from "../context/AuthContext";

    interface Note { id: string; title: string; description: string; }

    interface ApiResponse { notes: Note[]; }

    const fetchNotes = () => { const { token } = useAuth(); const [notes, setNotes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null);

    useEffect(() => { const fetchData = async () => { try { if(!token) throw new Error("Token not found!");

    const response = await axios.get("localhost:8080/fetchNotes", { headers: { Authorization: Bearer ${token}, }, });

    setNotes(response.data.notes); } catch (error: any) { setError(error.response?.data || error.message); } finally { setLoading(false); } }

    fetchData(); }, [notes, token])

return { notes, loading, error }; }

export default fetchNotes;


    Next, first let's create a card for our notes. In the `/components` folder create a new file called `NotesCard.tsx` and include the following code in it:

    ```typescript
    import React, { useState } from 'react';

    interface NotesCardProps {
      id: string;
      title: string;
      description: string;
    }

    const NotesCard: React.FC<NotesCardProps> = ({ id, title, description }) => {

      return (
        <div className="relative h-[18rem] w-[25rem] border-2 border-black rounded-xl">
          <div className="h-[4rem] bg-blue-700 rounded-tr-xl rounded-tl-xl text-white flex items-center p-4">
            {title}
          </div>

          <div className="flex items-center p-4 h-[9rem] my-[1rem] overflow-scroll">
            {description}
          </div>

          <div className="h-[3rem] w-[100%] rounded-br-xl rounded-bl-xl bg-blue-700 text-white absolute bottom-0 flex flex-row items-center justify-center gap-x-8">
            <div className="cursor-pointer">
              Edit
            </div>
            <div className="cursor-pointer">
              Delete
            </div>
          </div>
        </div>
      );
    };

    export default NotesCard;

The card would look something like this:

You can change the design according to your taste.

  1. Next, let's create a new folder called /notes and a file called page.tsx in it.

    The file would have the following code:

    'use client'
    import React, { useState, useEffect } from 'react';
    import NotesCard from '../components/NotesCard';
    import { useAuth } from '../context/AuthContext';
    import fetchNotes from '../hooks/fetchNotes';
    
    const Notes = () => {
      const { token } = useAuth();
      const [isMounted, setIsMounted] = useState(false);
      const { notes, loading, error } = fetchNotes();
    
    // This part exists to resolve the hydration error.
      useEffect(() => {
        setIsMounted(true);
      }, []);
    
      if (!isMounted) {
        return null;
      }
    
      const handleModal = () => {
        setIsModalOpen(!isModalOpen);
      };
    
      return (
        <div>
          {token ? (
            <>
              <div className="flex flex-row justify-between px-20 items-center mt-[8rem]">
                <h1 className="text-[2rem]">Your Notes</h1>
                <button className="flex p-4 rounded-xl bg-blue-700 text-white">
                  Add a new note
                </button>
              </div>
              <div className="flex flex-row px-12 flex-wrap gap-10 h-screen w-screen items-center justify-center" style={{ paddingTop: '4rem', paddingBottom: '60rem' }}>
                {loading ? (
                  <p>Loading...</p>
                ) : error ? (
                  <p>Error: {error}</p>
                ) : notes.length > 0 ? (
                  notes.map(note => (
                    <NotesCard key = {note.id} id = {note.id} title = {note.title} description = {note.description} />
                  ))
                ) : (
                  <div className="flex w-screen h-screen justify-center text-[3rem]">
                    No notes found!!! Add a new note.
                  </div>
                )}
              </div>
            </>
          ) : (
            <div className="flex w-screen h-screen justify-center items-center text-[3rem]">
              You aren't signed in, sign in first or create a new account!!!
            </div>
          )}
        </div>
      );
    };
    
    export default Notes;
    

    You can see how we have an option to add a new note and along with that all the Notes that exist in the database for the currently logged in user.

    The /notes page would look something like:

  2. Now, the three features that are left are to add a new note, to edit a note and to delete a note.

  3. First let's start with implementing the Add a new note feature. For that we will follow the same procedure. Create a new hook useAddNote.ts with the following content in it:

    import axios from "axios";
    import { useAuth } from "../context/AuthContext";
    
    interface ApiResponse {
        success: boolean;
        data: any;
    }
    
    const useAddNote = () => {
        const { token } = useAuth();
    
        const addNote = async(title: string, description: string): Promise<ApiResponse> => {
            try {
                const response = await axios.post<ApiResponse>(
                    "http://localhost:8080/addNote",
                    { title, description },
                    {
                        headers: {
                            Authorization: `Bearer ${token}`,
                        },
                    }
                );
    
                return response.data;
            } catch(error) {
                console.log("Error adding note: ", error);
                throw error;
            }
        }
    
        return { addNote };
    }
    
    export default useAddNote;
    

    Then, we need to create a modal, that will open up upon clicking the button: Add a new note, you can put the title and description of the note and then click on submit to view your new note.

    For doing the same create a new component called NoteModal.tsx and include the following code in it:

    import React, { useState, FormEvent } from 'react';
    import useAddNote from '../hooks/useAddNote';
    
    interface NoteModalProps {
      onClose: () => void;
    }
    
    const NoteModal: React.FC<NoteModalProps> = ({ onClose }) => {
      const [title, setTitle] = useState('');
      const [description, setDescription] = useState('');
      const { addNote } = useAddNote();
    
      const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();
    
        try {
          const response = await addNote(title, description);
          onClose();
        } catch (error) {
          // Handle errors
          console.error('Error adding note:', error);
        }
      };
    
      return (
        <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50">
          <div className="bg-white w-[27rem] h-[28rem] rounded-xl border-2 border-black flex flex-col items-center justify-center relative">
            <h1
              onClick={onClose}
              className="absolute right-4 top-4 cursor-pointer"
            >
              X
            </h1>
            <form
              onSubmit={handleSubmit}
              className="flex flex-col items-center justify-center gap-y-4"
            >
              <input
                type="text"
                placeholder="Title"
                value={title}
                onChange={(e) => setTitle(e.target.value)}
                className="w-[22rem] h-[3rem] border-2 border-black p-2 rounded-lg"
              />
              <textarea
                placeholder="Description"
                value={description}
                onChange={(e) => setDescription(e.target.value)}
                className="w-[22rem] h-[15rem] border-2 border-black p-2 rounded-lg"
              />
              <button
                type="submit"
                className="bg-blue-700 text-white p-4 rounded-lg w-[22rem] h-[3rem] flex items-center justify-center"
              >
                Submit
              </button>
            </form>
          </div>
        </div>
      );
    };
    
    export default NoteModal;
    

    Then go to your notes page code and update the implementation as:

    'use client'
    import React, { useState, useEffect } from 'react';
    import NotesCard from '../components/NotesCard';
    import { useAuth } from '../context/AuthContext';
    import fetchNotes from '../hooks/fetchNotes';
    import NoteModal from '../components/NoteModal';
    
    const Notes = () => {
      const { token } = useAuth();
      const [isMounted, setIsMounted] = useState(false);
      const { notes, loading, error } = fetchNotes();
    
      const [isModalOpen, setIsModalOpen] = useState(false);
    
      useEffect(() => {
        setIsMounted(true);
      }, []);
    
      if (!isMounted) {
        return null;
      }
    
      const handleModal = () => {
        setIsModalOpen(!isModalOpen);
      };
    
      return (
        <div>
          {token ? (
            <>
              <div className="flex flex-row justify-between px-20 items-center mt-[8rem]">
                <h1 className="text-[2rem]">Your Notes</h1>
                <button className="flex p-4 rounded-xl bg-blue-700 text-white" onClick={handleModal}>
                  Add a new note
                </button>
              </div>
              <div className="flex flex-row px-12 flex-wrap gap-10 h-screen w-screen items-center justify-center" style={{ paddingTop: '4rem', paddingBottom: '60rem' }}>
                {loading ? (
                  <p>Loading...</p>
                ) : error ? (
                  <p>Error: {error}</p>
                ) : notes.length > 0 ? (
                  notes.map(note => (
                    <NotesCard key = {note.id} id = {note.id} title = {note.title} description = {note.description} />
                  ))
                ) : (
                  <div className="flex w-screen h-screen justify-center text-[3rem]">
                    No notes found!!! Add a new note.
                  </div>
                )}
              </div>
            </>
          ) : (
            <div className="flex w-screen h-screen justify-center items-center text-[3rem]">
              You aren't signed in, sign in first or create a new account!!!
            </div>
          )}
    
           {isModalOpen && <NoteModal onClose={handleModal}/>}
        </div>
      );
    };
    
    export default Notes;
    

    Notice how we created a new useState variable for handling the opening and closing of the modal and then created a function too for toggling of modal and passed that function to Add a new note button. Now, upon clicking that button you will see a modal open up, using which you can create a new note as required. AND BINGO!!! Your note will be shown on the /notes page as expected.

  4. Now, let's implement the feature to delete a note, for this create a hook useDeleteNote.ts

    import axios from "axios";
    import { useAuth } from "../context/AuthContext";
    
    interface ApiResponse {
      success: boolean;
      data: any; 
    }
    
    const useDeleteNote = () => {
      const { token } = useAuth();
    
      const deleteNote = async (noteId: string): Promise<ApiResponse> => {
        try {
          const response = await axios.delete<ApiResponse>(`http://localhost:8080/deleteNote/${noteId}`, {
            headers: {
              Authorization: `Bearer ${token}`,
            },
          });
    
          return response.data;
        } catch (error) {
          console.error("Error deleting note: ", error);
          throw error;
        }
      };
    
      return { deleteNote };
    };
    
    export default useDeleteNote;
    

    Next, go to your NotesCard.tsx file and update the code as follows:

    import React, { useState } from 'react';
    import useDeleteNote from '../hooks/useDeleteNote';
    import { ToastContainer, toast } from 'react-toastify';
    import 'react-toastify/dist/ReactToastify.css';
    
    interface NotesCardProps {
      id: string;
      title: string;
      description: string;
    }
    
    const NotesCard: React.FC<NotesCardProps> = ({ id, title, description }) => {
      const { deleteNote } = useDeleteNote();
    
      const handleDeleteNote = async () => {
        try {
          const response = await deleteNote(id);
          if(response.success){
            toast.success("Note deleted successfully");
          }else{
            toast.error("Failed to delete note");
          }
        } catch(error) {
          toast.error("Failed to delete note");
        }
      }
    
      return (
        <div className="relative h-[18rem] w-[25rem] border-2 border-black rounded-xl">
          <div className="h-[4rem] bg-blue-700 rounded-tr-xl rounded-tl-xl text-white flex items-center p-4">
            {title}
          </div>
    
          <div className="flex p-4 h-[9rem] my-[1rem] overflow-scroll">
            {description}
          </div>
    
          <div className="h-[3rem] w-[100%] rounded-br-xl rounded-bl-xl bg-blue-700 text-white absolute bottom-0 flex flex-row items-center justify-center gap-x-8">
            <div className="cursor-pointer">
              Edit
            </div>
            <div className="cursor-pointer" onClick={handleDeleteNote}>
              Delete
            </div>
          </div>
    
          <ToastContainer/>
        </div>
      );
    };
    
    export default NotesCard;
    

    Notice, how I called the deleteNote hook inside handleDeleteNote function and passed it to the Delete option. Now, you will be able to delete a note.

  5. Finally, we need to add a feature to edit a particular note. For that, the idea is simple; upon clicking the Edit button, a modal shall open up displaying the current content of the Note, you can change the content and click on submit thus updating the content.

    For this, first create the hook to implement the edit note route called useEditNote.ts.

    // useEditNote.ts
    import axios from 'axios';
    import { useAuth } from '../context/AuthContext';
    
    interface ApiResponse {
      success: boolean;
      data: any;
    }
    
    const useEditNote = () => {
      const { token } = useAuth();
    
      const editNote = async (noteId: string, title: string, description: string): Promise<ApiResponse> => {
        try {
          const response = await axios.patch<ApiResponse>(
            `http://localhost:8080/editNote/${noteId}`,
            { title, description },
            {
              headers: {
                Authorization: `Bearer ${token}`,
              },
            }
          );
    
          return response.data;
        } catch (error) {
          console.error('Error editing note:', error);
          throw error;
        }
      };
    
      return { editNote };
    };
    
    export default useEditNote;
    

    Then create a new component called EditNoteModal.tsx, and call the required hook in it to update the content.

    import React, { useState, FormEvent } from 'react';
    import useEditNote from '../hooks/useEditNote';
    
    interface EditNoteModalProps {
      noteId: string;
      initialTitle: string;
      initialDescription: string;
      onClose: () => void;
    }
    
    const EditNoteModal: React.FC<EditNoteModalProps> = ({ noteId, initialTitle, initialDescription, onClose }) => {
      const { editNote } = useEditNote();
      const [title, setTitle] = useState(initialTitle);
      const [description, setDescription] = useState(initialDescription);
    
      const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();
    
        try {
          const response = await editNote(noteId, title, description);
          onClose();
        } catch (error) {
          console.error('Error editing note:', error);
        }
      };
    
      return (
        <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50">
          <div className="bg-white w-[27rem] h-[28rem] rounded-xl border-2 border-black flex flex-col items-center justify-center relative">
            <h1 className="absolute right-4 top-4 cursor-pointer" onClick={onClose}>
              X
            </h1>
            <form className="flex flex-col items-center justify-center gap-y-4" onSubmit={handleSubmit}>
              <input
                type="text"
                placeholder="Title"
                className="w-[22rem] h-[3rem] border-2 border-black p-2 rounded-lg"
                value={title}
                onChange={(e) => setTitle(e.target.value)}
              />
              <textarea
                placeholder="Description"
                className="w-[22rem] h-[15rem] border-2 border-black p-2 rounded-lg"
                value={description}
                onChange={(e) => setDescription(e.target.value)}
              />
              <button className="bg-blue-700 text-white p-4 rounded-lg w-[22rem] h-[3rem] flex items-center justify-center" type="submit">
                Submit
              </button>
            </form>
          </div>
        </div>
      );
    };
    
    export default EditNoteModal;
    

    Then, go to the /notes page code, and update the code as follows:

    'use client'
    import React, { useState, useEffect } from 'react';
    import NotesCard from '../components/NotesCard';
    import { useAuth } from '../context/AuthContext';
    import fetchNotes from '../hooks/fetchNotes';
    import NoteModal from '../components/NoteModal';
    
    const Notes = () => {
      const { token } = useAuth();
      const [isMounted, setIsMounted] = useState(false);
      const { notes, loading, error } = fetchNotes();
    
      const [isModalOpen, setIsModalOpen] = useState(false);
    
      useEffect(() => {
        setIsMounted(true);
      }, []);
    
      if (!isMounted) {
        return null;
      }
    
      const handleModal = () => {
        setIsModalOpen(!isModalOpen);
      };
    
      return (
        <div>
          {token ? (
            <>
              <div className="flex flex-row justify-between px-20 items-center mt-[8rem]">
                <h1 className="text-[2rem]">Your Notes</h1>
                <button className="flex p-4 rounded-xl bg-blue-700 text-white" onClick={handleModal}>
                  Add a new note
                </button>
              </div>
              <div className="flex flex-row px-12 flex-wrap gap-10 h-screen w-screen items-center justify-center" style={{ paddingTop: '4rem', paddingBottom: '60rem' }}>
                {loading ? (
                  <p>Loading...</p>
                ) : error ? (
                  <p>Error: {error}</p>
                ) : notes.length > 0 ? (
                  notes.map(note => (
                    <NotesCard key = {note.id} id = {note.id} title = {note.title} description = {note.description} />
                  ))
                ) : (
                  <div className="flex w-screen h-screen justify-center text-[3rem]">
                    No notes found!!! Add a new note.
                  </div>
                )}
              </div>
            </>
          ) : (
            <div className="flex w-screen h-screen justify-center items-center text-[3rem]">
              You aren't signed in, sign in first or create a new account!!!
            </div>
          )}
    
           {isModalOpen && <NoteModal onClose={handleModal}/>}
        </div>
      );
    };
    
    export default Notes;
    

    Notice, how upon clicking a particular note's Edit option, the EditNoteModal opens up and it will do the required job.

Bingo!!! There goes your complete full stack app built from scratch.


Here's the GitHub link to the project:

https://github.com/swapn652/MemoEase

I have also opened some issues that you can try out for practice.


You can reach out to me on:

  1. Twitter

  2. Showwcase

Did you find this article valuable?

Support Swapnil Pant by becoming a sponsor. Any amount is appreciated!