MERN with auth


tech stack

  • NodeJs
  • Express
  • Mongodb
  • Mongoose
  • JWT
  • React
  • Vite

features

  • User can create profile / register
  • User can login
  • User can view profile
  • User can update profile
  • User can logout
  • View profile and update profile need authentication

init project

  • Run pnpm init to create package.json in the root directory.

  • package.json inside Root will contain server side dependencies.

  • backend will contain the backend source code.

  • frontend will contain the react frontend related stuff.

  • Install few required dependencies in the root package.json

  • Head over to the code files inside the repo in case of any error (especially for TS).

    
      pnpm add express dotenv mongoose bcryptjs jsonwebtoken cookie-parser express-async-handler
    
      pnpm add -D @types/bcryptjs @types/cookie-parser @types/express @types/jsonwebtoken concurrently
    
  • backend/server.ts is the entry point for our backend (api) server.

  • In package.json add the below lines

    
      "type": "module",
    
      "scripts": {
        "dev": "concurrently \"tsc --watch\" \"node --watch dist/server.js\"",
        "start": "tsc && node dist/server.js"
      }
    
  • Add tsc config file tsconfig.json:

    {
      "compilerOptions": {
        "target": "es6",
        "module": "ES6",
        "sourceMap": true,
        "outDir": "dist",
        "esModuleInterop": true,
        "moduleResolution": "nodenext"
      },
      "include": ["backend/**/*.ts"]
    }
  • Create a sinple express server:

    import express from 'express';
    
    const PORT = process.env.PORT || 5000;
    const app = express();
    
    app.get('/', (_, res) => res.send('server is ready'));
    
    app.listen(PORT, () => console.log('Serving on http://localhost:' + PORT));
  • Now we should be able to run our server with pnpm dev

  • Create .env file

    
      NODE_ENV=development
      PORT=5001
      MONGO_URI="mongodb+srv://<user>:<password>@mernauth.ng8fjlu.mongodb.net/mernauth?retryWrites=true&w=majority"
      JWT_SECRET=jwtSecretMustBeStrong
    
  • Now we can go inside server.js and add the following:

    import dotenv from 'dotenv';
    dotenv.config();
  • Create .gitignore:

    
      node_modules
      dist
    
      .env
    
  • We will be creating the following routes

    MethodRouteWorkingAccess
    POSTapi/usersregister a userPublic
    POSTapi/users/authauthenticate a user and get tokenPublic
    POSTapi/users/logoutlogout user and clear cookiePublic
    GETapi/users/profileget user profilePrivate
    PUTapi/users/profileupdate profilePrivate
  • We will not do all of this in server.ts file instead we will create a new folder routes.

  • Inside routes we will create a file userRoutes.ts.

  • We could put all of our logic in userRoutes file but it is good practice to keep that in a differnt place.

  • So we will create another folder controllers inside which we will create a file userController.ts

  • Inside this controller file:

    import type { Request, Response } from 'express';
    
    // @desc     Auth user / Set token
    // @route    POST /api/users/auth
    // @access   Public
    const authUser = (req: Request, res: Response) => {
      res.status(200).json({ message: 'Auth User' });
    };
    
    export { authUser };
  • Now inside the routes/userRouter.ts:

    import express from 'express';
    import { authUser } from '../controllers/userController.js';
    
    const router = express.Router();
    
    // api/users will be connected to this whole file so here only '/auth'
    router.post('/auth', authUser);
    
    export default router;
  • Now inside our server.ts add these lines:

    import userRoutes from './routes/userRoutes.js';
    
    // connect '/api/users' to userRoutes
    app.use('/api/users', userRoutes);

Postman

  • Now to check if everything is working ok we will move on to the postman:

    1. Create new workspace with name ‘Mern with auth’
    2. Create new environment with maybe same name
    3. Add a variable to this environment:
    {
      "variable": "baseUrl",
      "type": "default",
      "initial value": "http://localhost:5001/api",
      "current value": "http://localhost:5001/api"
    }
    1. save and choose the added environment
    2. Create a new request:
    {
      "method": "POST",
      "URL": "{{baseUrl}}/users/auth"
    }
    1. send the request and we will get the response.
    2. To save the collections go to collections tab and click create collection name it as users, inside collections click on add a request and name the request as Login user.
    3. Now we should be good to add the new requests to this collection in future.
  • Use asyncHandler to async await without try catch:

    
      import asyncHandler from 'express-async-handler'
    
      const authUser = asyncHandler (
        async (req, res) => {
        ...
        }
      )
    

Middlewares

express.json(): Parses JSON request bodies.

express.urlencoded(): Parses URL-encoded request bodies.

cookieParser(): Parses cookie headers and populates req.cookies with the parsed cookies.

  • Now to make the custom error handler we will create a middleware folder inside which we will do all the middleware related stuff. For handling the errors we will create errorMiddleware.ts

  • The default error message for express is an html file but we want the errors in json format.

  • Inside errorMiddleware.ts :

    import { Request, Response, NextFunction } from 'express';
    
    const notFound = (req: Request, res: Response, next: NextFunction) => {
      const error = new Error(`${req.originalUrl} not found`);
    
      res.status(404);
      next(error);
    };
    
    const errorHandler = (err: CustomError, _: Request, res: Response) => {
      let statusCode = res.statusCode === 200 ? 500 : res.statusCode;
      let message = err.message;
    
      if (err.name === 'CastError' && err.kind === 'ObjectId') {
        statusCode = 400;
        message = 'Resource not found';
      }
    
      res.status(statusCode).json({
        message,
        stack: process.env.NODE_ENV === 'production' ? null : err.stack,
      });
    };
    
    export { notFound, errorHandler };
  • Now we have to bring them in the server.ts:

    import { errorHandler, notFound } from './middleware/errorMiddleware.js';
    
    // below requests app.use('/api/users', userRoutes);
    app.use(notFound);
    app.use(errorHandler);
  • Now let’s create rest of our routes:

    // @desc      Register a new user
    // @route     POST api/users
    // @access    Public
    
    const registerUser = asyncHandler(async (req: Request, res: Response) => {
      res.status(200).json({ message: 'register user' });
    });
    
    // and so on ... others should be pretty smilar
    // remember to export all of them
    // refer to the table above where all routes are listed.
  • After creating all of these routes we need to add them to userRoutes.ts:

    router.post('/', registerUser);
    // Add others in the similar way as we did registerUser and authUser in this file
    
    // Since route for the Get Profile and Update Profile is same so we can do it in two ways:
    router.get('/profile', getUserProfile);
    router.put('/profile', updateUserProfile);
    
    // Or the other way in single line
    router.route('/profile').get(getUserProfile).put(updateUserProfile);
  • After all this we will create the requests in postman:

    MethodNameRoute
    POSTLogin User{{baseUrl}}/users/auth
    POSTRegister User{{baseUrl}}/users
    POSTLogout User{{baseUrl}}/users/logout
    GETGet User Profile{{baseUrl}}/users/profile
    PUTUpdate User Profile{{baseUrl}}/users/profile
  • Now our requests are saved, we will add data (which we want to post eg: email password) in the postman as x-www-form-urlencoded later. And for now we will move on towards database setup.

Database (MongoDB)

  • MongoDB database Setup

    • Login to MongoDB
    • Create organization (if not already created)
    • Create a project with name as mernauth
    • Click Build Database choose the required options and click create
    • Create user for database (username, password) keep password inside clipboard for future.
    • Add current IP or allow all ips (0.0.0.0/0)
    • Finish and close
    • collections > add my own data > db = mernauth, collection = users > save
    • Go back to main screen and click connect > drivers > nodejs and copy the connection string
    • Go inside the .env and replace value of MONGO_URI with the copied string and replace <password> with actual password, insert the db name mernauth in between the /? inside the connection string.
    • That’s our database fully setup and added to our app.
  • Inside the backend folder create another folder config with file db.ts:

    import mongoose from 'mongoose';
    
    let conn = null;
    
    const connectDB = async () => {
      if (conn) {
        console.log(
          'Already connected to db: |' + conn.connection.db.databaseName + '|'
        );
        return conn; // Return the existing connection if available
      }
    
      try {
        conn = await mongoose.connect(process.env.MONGO_URI);
        console.log('Connected to db: ' + conn.connection.db.databaseName);
        return conn;
      } catch (error) {
        console.log('Error: ' + error.message);
        process.exit(1);
      }
    };
    
    export default connectDB;
  • Now execute this function at the top of server.ts file:

    import connectDB from './config/db.js';
    
    connectDB();
    // above the const app = express()
  • Create the data model for users using mongoose inside backend/models/userModel.js:

    import { Schema, model } from 'mongoose';
    
    interface IUser extends Document {
      name: string;
      email: string;
      password: string;
      createdAt: string;
      updatedAt: string;
      matchPasswords: (password: string) => Promise<boolean>; // Needed later
    }
    
    const userSchema: Schema<IUser> = new Schema(
      {
        email: {
          type: String,
          required: true,
          unique: true,
        },
        // similarly we have name and password without unique property
      },
      {
        timestamps: true, // updated & created at
      }
    );
    
    const User = model('User', userSchema);
    
    export default User;
  • Now inside userController.ts let’s grab the created User model:

    import User from '../Models/userModel.js';
    
    // log the request body inside the registerUser function:
    console.log(req.body);
  • This may log the body as undefined as request body is json while we are not parsing json body so let’s make use of json parser from express inside our server.ts file:

    // under const app = express()
    app.use(express.json());
    
    // to parse url encoded data sent from postman use urlencoded({ extended: true })
    app.use(express.urlencoded({ extended: true }));
  • Now go inside postman > choose Register User request > body > x-www-form-urlencoded and inside that enter the following keys & values:

    • key = name
    • value = jhon
  • If you remember that previously while sending the same request the undefined was logged inside the console but this time we will the actual json { name: "jhon" } and now we are able to destructure the name from req.body as:

    const { name } = req.body;
  • But we want to pull out the name, email, password from the req.body so inside the userController.ts we will do it like this:

    const { name, email, password } = req.body;
  • After we got the user details we will check if the user already exists:

    const userExists = await User.findOne({ email });
    
    if (userExists) {
      res.status(400);
      throw new Error('User already exists');
    }
    
    // If the user does not exist
    const user = await User.create({
      name,
      email,
      password, // we will hash it after some time inside userModel.ts
    });
    
    // If user was successfully created
    if (user) {
      res.status(201).json({
        _id: user._id,
        name: user.name,
        email: user.email,
        // we will save the cookie in http only format so we are not putting that here.
      });
    } else {
      res.status(422);
      throw new Error('Invalid user data');
    }
    
    // Don't forget to remove that last line (res.status....)

Password Hashing

  • Now before moving on let’s make sure that we hash the password, for this we can make use of the methods such as pre, post which exist on schemas. let’s move inside userModel.ts:

    import bcrypt from 'bcryptjs';
    
    // Here we need to use the this keyword so we are not using arrow function
    userSchema.pre('save', async function (next: NextFunction) {
      // `this` here refers to the newly created user.
      if (!this.modified('password')) {
        next(); // Move on to the next middleware if password is not modified.
      }
    
      // Otherwise if password is modified:
      const salt = await bcrypt.genSalt(10);
      this.password = await bcrypt.hash(this.password, salt);
    });
    
    // before creating the model const User = model('User', userSchema)
  • Now if we make the postman request to the saved Register User with the required keys and values name, email, password we should get back the _id, name, email of the created user. You can check it from the MongoDb as well with some extra fields hashedPassword, createdAt, updatedAt. So if everything is working fine let’s proceed further.

JWT

  • We could put all the token creation logic inside userController.ts but it would be better to keep that seperate. so let’s create one more file inside backend utils/generateToken.ts:

    import jwt from 'jsonwebtoken';
    
    const generateToken = (res, userId) => {
      const token = jwt.sign({ userId }, process.env.JWT_SECRET, {
        expiresIn: '30d',
        // we can make it something like this as well: `exporesIn: 1 * 60 * 60 * 1000` = 1 hour
      });
    
      // Save it into the cookies
      res.cookie('jwt', token, {
        httpOnly: true,
        secure: process.env.NODE_ENV !== 'development',
        sameSite: 'strict',
        maxAge: 1 * 60 * 60 * 1000, // same as above expiresIn
      });
    };
    
    export default generateToken;
  • Move on to the userController.ts to add this generateToken

    
      if (user) {
        generateToken(res, user._id); // make sure to import it
    
        ...
      }
    
  • Now if we register a new user in postman, we can see cookies tab has (1) in front of it means that 1 cookie is set.

    • If we click on the tab we can see the token with all details such as:

      • name = jwt
      • value = someLongRandomCharacterString
      • domain, expires, httpOnly etc…
    • Also under the send button we can see the cookies link which takes us inside the manage cookies tab where we can cross jwt in order to delete cookie.

    • This cookie means that we are logged in and if we delete this cookie or if the cookie gets expired means that we are logged out.

    • We don’t have the logout route set yet so we will cross the jwt in order to logout.

    • Let’s save this Request in postman and move forward with login.

  • Since we understood the logout function well so the login should be pretty easy, move on to the authUser function inside userController.ts:

    const { email, password } = req.body;
    
    const user = await User.findOne({ email });
    
    if (user && (await user.matchPasswords(password))) {
      generateToken(res, user._id);
      res.status(201).json({
        _id: user._id,
        name: user.name,
        email: user.email,
      });
    } else {
      res.status(401);
      throw new Error('Invalid Email or Password');
    }
  • We are not checking for the password above but we need to do it anywhere, so let’s do it inside userModel.ts:

    // under userSchema.pre middleware function
    userSchema.methods.matchPasswords = async function (enteredPassword) {
      return await bcrypt.compare(enteredPassword, this.password);
    };
  • Now if we make Request to the login route via Postman we should see the cookie set.

  • To setup the logout route let’s move on to the logoutUser function inside userController.ts:

    res.cookie('jwt', '', {
      httpOnly: true,
      expires: new Date(0),
    });
    
    // change response message to `logged out`.

Protect routes

  • Now that we can check logout request inside postman and move on to protect the routes using this jwt cookie, for this let’s create a middleware authMiddleware.ts.

    • Here we will need to parse the cookie using cookie-parser

    • Inside server.ts use cookieParser():

      // after urlencoded()
      app.use(cookieParser());
    • Inside authMiddleware.ts:

      import jwt from 'jsonwebtoken';
      // Because we need to get the payload from the token which is userId
      // We need to verify the token
      import asyncHandler from 'express-async-handler';
      import User from '../models/userModel.js';
      
      // function to protect routes:
      const protect = asyncHandler(
        async (req: Request, res: Response, next: NextFunction) => {
          let token = null;
      
          token = req.cookie.jwt;
      
          if (token) {
            try {
              const decoded = jwt.verify(token, process.env.JWT_SECRET);
              // decoded will contain Id because we passed Id to it at the time of signing `jwt.sign()`
      
              req.user = await User.findById(decoded.userId).select('-password'); // don't give password
            } catch (err) {
              res.status(401);
              throw new Error('Not authorized, invalid token');
            }
          } else {
            res.status(401); // unauthorized
            throw new Error('Not authorized, no token');
          }
        }
      );
      
      export { protect };
      // we may need to export other middlewares so named export instead of default
    • Now inside userRoutes.js we need to bring this middleware

      import { protect } from '../middleware/authMiddleware.js';
      
      // use protect as the first argument to .get, .put method:
      // here we want to protect getUserProfile and updateUserProfile so:
      router
        .route('/profile')
        .get(protect, getUserProfile)
        .put(prtoect, updateUserProfile);
  • Now if we try to access these routes in postmant we will get Not authorized error if we are not logged in, or will return the user (in case of Get User Profile) if we add the following code to getUserProfile function:

    const { _id, name, email } = req.user;
    
    res.status(200).json({ _id, name, email });
  • The last thing left is the updateUserProfile:

    // Get the current user details:
    const user = await User.findById(req.user._id);
    
    // ToDo: throw error if the specified email is already associated with another user.
    
    // If any such user exists then update with newly provided details:
    if (user) {
      user.name = req.body.name || user.name;
      user.email = req.body.email || user.email;
    
      // if user has provided the password then update the password:
      if (req.body.password) user.password = req.body.password;
    
      // save the user and get the updatedUser:
      const { _id, name, email } = await user.save();
    
      res.status(200).json({ _id, name, email });
    }
  • Now we can update anything we want (email, name, password) by making a request in postman and passing the data as body/x-www-form-urlencorded. eg: we may pass in the new email ’jhon@mail.eu’. The email for the logged in user will be updated. We can check it from mongodb as well.

  • Congratulations: Our backend is fully complete and all set, now we will move on to the frontend part.

Important note: Before moving on we must be aware of this thing that this backend can be integrated with any frontend tecnology such as react, vue, svelte, reactNative or even vanella js.

  • Backend Final docs

Frontend

  • Setting up the project using vite:

    • run the command pnpm create vite frontend it will ask you a few questions choose react and ts as the option.

    • run the commands shown in the terminal cd frontend.........

    • under the plugins line in vite.config.ts file add this:

      
        server: {
          port: 3000,
          proxy: {
            '/api': {
              target: 'http://localhost:5001',
              changeOrigin: true,
            },
          },
        },
      
      • this will change the default frontend port to 3000.
      • will prefix every request starting by /api with http://localhost:5001
    • add the below script in the root package.json file.

      
        "client": "concurrently \"tsc && node dist/server.js\" \"cd frontend && pnpm dev\""
      
    • now start frontend server using pnpm client this will also start the backend concurrently.

  • Now let’s do some cleanup and add some dependencies:

    • Delete App.css and replace all content inside App.tsx with rafce shortcut.
    • install the below dependencies:
      
        "bootstrap": "^5.2.3",
        "react-bootstrap": "^2.7.4",
        "react-icons": "^4.8.0",
        "@reduxjs/toolkit": "^1.9.5",
        "react-redux": "^8.0.5",
        "react-router-bootstrap": "^0.26.2",
        "react-router-dom": "^6.11.2"
      
    • and devDependencies:
    
      "@types/react-router-bootstrap": "^0.26.0"
    
    • Cut all content inside index.css
    • Since we already have a .gitignore file so lets cut the contents of the .gitignore which is inside the frontend folder and paste that contents inside .gitignore which is in the root of our folder structure.
    • Add this import to main.tsx:
      import 'bootstrap/dist/css/bootstrap.min.css';
      // above index.css
  • Now that the setup is complete let’s move on to building the frontend. So let’s create a folder named components inside the src folder.

  • I prefer to export all components from a single file so that to make the multiple imports cleaner that how they would look otherwise. So let’s create an index.ts file inside the components folder and from this file we will be exporting everything.

  • Now create one more file components/Header.tsx with rafce and export it from components/index.ts

    export { default as Header } from './Header';

Project link