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 initto create package.json in the root directory. -
package.jsoninside Root will contain server side dependencies. -
backendwill contain the backend source code. -
frontendwill 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.tsis the entry point for our backend (api) server. -
In
package.jsonadd 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
.envfileNODE_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
Method Route Working Access POST api/users register a user Public POST api/users/auth authenticate a user and get token Public POST api/users/logout logout user and clear cookie Public GET api/users/profile get user profile Private PUT api/users/profile update profile Private -
We will not do all of this in server.ts file instead we will create a new folder
routes. -
Inside
routeswe will create a fileuserRoutes.ts. -
We could put all of our logic in
userRoutesfile but it is good practice to keep that in a differnt place. -
So we will create another folder
controllersinside which we will create a fileuserController.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.tsadd 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:
- Create new workspace with name ‘Mern with auth’
- Create new environment with maybe same name
- Add a variable to this environment:
{ "variable": "baseUrl", "type": "default", "initial value": "http://localhost:5001/api", "current value": "http://localhost:5001/api" }- save and choose the added environment
- Create a new request:
{ "method": "POST", "URL": "{{baseUrl}}/users/auth" }- send the request and we will get the response.
- To save the collections go to
collectionstab and clickcreate collectionname it asusers, inside collections click onadd a requestand name the request asLogin user. - Now we should be good to add the new requests to this collection in future.
-
Use
asyncHandlertoasync awaitwithouttry 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
middlewarefolder inside which we will do all the middleware related stuff. For handling the errors we will createerrorMiddleware.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:
Method Name Route POST Login User {{baseUrl}}/users/auth POST Register User {{baseUrl}}/users POST Logout User {{baseUrl}}/users/logout GET Get User Profile {{baseUrl}}/users/profile PUT Update 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-urlencodedlater. 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 Databasechoose the required options and clickcreate - 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
.envand replace value ofMONGO_URIwith the copied string and replace<password>with actual password, insert the db namemernauthin between the/?inside the connection string. - That’s our database fully setup and added to our app.
-
Inside the backend folder create another folder
configwith filedb.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.tsfile: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.tslet’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
undefinedas request body isjsonwhile we are not parsing json body so let’s make use of json parser from express inside ourserver.tsfile:// 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 Userrequest > body >x-www-form-urlencodedand inside that enter the following keys & values:- key = name
- value = jhon
-
If you remember that previously while sending the same request the
undefinedwas logged inside the console but this time we will the actual json{ name: "jhon" }and now we are able to destructure the name fromreq.bodyas:const { name } = req.body; -
But we want to pull out the
name,email,passwordfrom 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,postwhich exist on schemas. let’s move insideuserModel.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 Userwith the required keys and valuesname, email, passwordwe should get back the_id, name, emailof the created user. You can check it from the MongoDb as well with some extra fieldshashedPassword, createdAt, updatedAt. So if everything is working fine let’s proceed further.
JWT
-
We could put all the token creation logic inside
userController.tsbut it would be better to keep that seperate. so let’s create one more file inside backendutils/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.tsto add thisgenerateTokenif (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
cookieslink 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
authUserfunction insideuserController.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
logoutUserfunction insideuserController.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.tsusecookieParser():// 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.jswe need to bring this middlewareimport { 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 authorizederror if we are not logged in, or will return the user (in case of Get User Profile) if we add the following code togetUserProfilefunction: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.
Final links:
- Backend Final docs
Frontend
-
Setting up the project using vite:
-
run the command
pnpm create vite frontendit will ask you a few questions choose react and ts as the option. -
run the commands shown in the terminal
cd frontend......... -
under the
pluginsline invite.config.tsfile 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
/apiwithhttp://localhost:5001
-
add the below script in the root
package.jsonfile."client": "concurrently \"tsc && node dist/server.js\" \"cd frontend && pnpm dev\"" -
now start frontend server using
pnpm clientthis will also start the backend concurrently.
-
-
Now let’s do some cleanup and add some dependencies:
- Delete
App.cssand replace all content insideApp.tsxwith 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
.gitignorefile so lets cut the contents of the.gitignorewhich is inside the frontend folder and paste that contents inside.gitignorewhich is in the root of our folder structure. - Add this import to
main.tsx:import 'bootstrap/dist/css/bootstrap.min.css'; // above index.css
- Delete
-
Now that the setup is complete let’s move on to building the frontend. So let’s create a folder named
componentsinside thesrcfolder. -
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.tsfile inside thecomponentsfolder and from this file we will be exporting everything. -
Now create one more file
components/Header.tsxwithrafceand export it fromcomponents/index.tsexport { default as Header } from './Header';