From Concept to Completion: Building a Full-Stack App with Image Functionality
Build a full-stack web app with MERN tech and Cloudinary
Intro
In this blog, we are going to create a full-stack web application that allows users to sign up, log in, and save images on the database. For this web application, we are going to use ReactJS for frontend, NodeJS, and ExpressJS for the backend server, Cloudinary for uploading images, and MongoDB for saving user details and image links.
So, what are we waiting for? Let's go...
1. Creating the BackEnd
Here we are going to create the backend server. First name your project, I am going to name it as image-app
, you can name it of your choice.
In the image-app
, create a backend
folder and open it in VS Code( or any other editor).
Open the terminal and run the command:-
npm init -y
It will create a package.json
file. Now, to create a backend server, we are going to install some packages:-
Express JS
for creating RESTful APIs.Mongoose
for simplifying interactions with the MongoDB database.Zod
for data validation.Cors
that allows a web page to share resources across different origins.
Before proceeding to the other things, get a connection string that helps to connect the database with the backend, to get a connection string log on to https://www.mongodb.com/, then go to Create a new Project, and go to the overview tab, click on Create as shown in the figure.
After finishing, you will be directed to the home page. Click on the connect button and get the connection string from there.
The connection string would look like this:-
mongodb+srv://<user>:<password>@
something.something.mongodb.net/
Connecting the Database to the backend server
Create a folder named
db
in thebackend
folder, and createindex.js
file.In this file, we are going to connect with the MongoDB database using Mongoose and also create schemas for User and Image.
const mongoose = require("mongoose");
mongoose.connect(
"mongodb+srv://<user>:<password>@something.something.mongodb.net/ "
);
const UserSchema = new mongoose.Schema({
firstName: {
type: String,
required: true,
trim: true,
maxLength: 15,
},
lastName: {
type: String,
require: true,
trim: true,
maxLength: 15,
},
password: {
type: String,
required: true,
minLength: 8,
},
email: {
type: String,
unique: true,
required: true,
},
});
const ImageSchema = new mongoose.Schema({
user: {
ref: "User",
type: mongoose.Schema.Types.ObjectId,
required: true,
},
imageName: {
type: String,
required: true,
},
imageLink: {
type: String,
required: true,
},
cloudinary_id: {
type: String,
required: true,
},
});
const User = mongoose.model("User", UserSchema);
const Image = mongoose.model("Image", ImageSchema);
module.exports = { User, Image };
Now you are going to ask why we have cloudinary_id and imageLink field in the image schema, it is because when we upload images to the Cloudinary, it returns a unique ID, and public link for the image to get access.
We have created schemas for the user and image. It's now time to create routes for them. But before that, it's a good practice to start the server and for that, we have to create a index.js
file in the backend
folder.
const express = require("express");
const app = express();
const rootRouter = require("./routes/index");
const cors = require("cors");
app.use(cors());
// to parse JSON data sent in the request body
app.use(express.json());
app.use(
express.urlencoded({
extended: true,
})
);
app.use("/api/v1", rootRouter);
app.listen(3000, (req, res) => {
console.log("Server is running");
});
we have used app.use("/api/v1",rootRouter)
in the index.js file. This is where we are going to define our routes.
- Creating Routes for the User and Image
In the backend
folder, create a folder named routes
and create index.js
, user.js
, and image.js
files.
index.js
file - This is where routes will be divided for user and image.
const { Router } = require("express");
const router = Router();
const UserRoute = require("./user");
const ImageRoute = require("./image");
router.use("/user", UserRoute);
router.use("/image", ImageRoute);
module.exports = router;
user.js
file - This is where we are going to write user routes and logic for creating a new user in the database.
As we know, users will write their email and password, it's a good practice to encrypt their password and save the encrypted password in the database.
For that purpose, we are going to install another npm package called bcryptjs
.
Run the command npm i bcryptjs
in the terminal.
First, we generate salt
using genSalt
function which comes with this package, with which we will hash the password using hash
function. Now, when the user tries to log in we will compare this hashed password with the input password by using compare
function present in the bcryptjs
package.
Also, we are going to use jsonwebtoken
package for stateless authentication, to learn more about jsonwebtoken
you can go to https://jwt.io/.
For creating a token we use sign
function and for verifying the token we use verify
function. We will see the use of all these things in our user.js
file. We will give this token in the request headers.
Earlier I told you that Zod
for validating data, we are going to use Zod here. We use Zod
for TypeScript-friendly schema validation, ensuring type safety and clean, reusable code with concise error reporting. Its declarative syntax and extensive features simplify data validation and serialization tasks in our applications. You can learn more about Zod
on https://zod.dev/
user.js
file will look like this:-
const { Router } = require("express");
const router = Router();
const zod = require("zod");
// User Schema from index.js file present in the db folder
const { User } = require("../db/index");
const jwt = require("jsonwebtoken");
const JWT_SECRET = "secretkey";
const bcrypt = require("bcryptjs");
// using zod for the validation checkpoint before signing in
const validateSignUpUser = zod.object({
firstName: zod.string(),
lastName: zod.string(),
password: zod.string(),
email: zod.string().email(),
});
// signing route for the user
router.post("/signUp", async (req, res) => {
const firstName = req.body.firstName;
const lastName = req.body.lastName;
const password = req.body.password;
const email = req.body.email;
// safeParsing the input fields using zod
const isValid = validateSignUpUser.safeParse({
firstName,
lastName,
password,
email,
});
// isValid return success in the form of true or false.
try {
// if true
if (isValid.success) {
// First we will check that is user is already present with that
// email of not.
const check = await User.find({ email });
if (check.length !== 0) {
res.status(411).json({
success: false,
message: "Email already taken / Incorrect Inputs",
});
} else {
// generating salt
const salt = await bcrypt.genSalt(10);
// hashing the password
const secPass = await bcrypt.hash(password, salt);
// creating the user and saving the information in the database
const user = await User.create({
firstName,
lastName,
email,
password: secPass,
});
const data = {
user: {
id: user.id,
},
};
// creating a token using sign function
const token = jwt.sign(data, JWT_SECRET);
res.status(200).json({
success: true,
message: "User Created Successfully",
token: token,
});
}
}
} catch (error) {
res.status(411).json({
success: false,
message: "wrong Credentials",
});
}
});
// validation checkpoint logging in
const validateSignInUser = zod.object({
email: zod.string().email(),
password: zod.string(),
});
// log in route for the user
router.post("/logIn", async (req, res) => {
const email = req.body.email;
const password = req.body.password;
const isValid = validateSignInUser.safeParse({
email,
password,
});
try {
if (isValid.success) {
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({
success: false,
message: "Please try to login with correct credentials",
});
}
// comparing the hashed password and the input password
const passwordCompare = await bcrypt.compare(password, user.password);
if (!passwordCompare) {
return res.status(400).json({
success: false,
message: "please try to login with correct credentials",
});
}
const data = {
user: {
id: user.id,
},
};
const token = jwt.sign(data, JWT_SECRET);
res.status(200).json({
token: token,
success: true,
message: "User logged in successfully",
});
}
} catch (error) {
res.status(411).json({
success: false,
message: "Error!!!",
});
}
});
module.exports = router;
And the routes for the user are created successfully. We have only created the signup
and login
routes. If you want to create routes for updating the details, getting user info, etc, you can create.
Don't you think, we should allow only authenticated users to upload images? Yes, you are right!!! We should. So, before the images route, we should make sure that only authenticated users are allowed. For that, we have to create a middleware.
In the backend
folder, create another folder named middleware
and create a file called authMiddleware.js
. In this file, we will write a logic for giving access to only authenticated users.
Here we are going to use the token that we created while signing up (or logging in). The token would be present in the request headers.
authMiddleware.js
file looks like this:-
const jwt = require("jsonwebtoken");
const { User } = require("../db");
const { JWT_SECRET } = require("../config");
// middleware for checking user is authenticated or not
const authMiddleware = async (req, res, next) => {
// getting the authorization header
const header = req.headers.authorization;
try {
if (header.startsWith("Bearer ")) {
const token = header.split("Bearer ")[1];
// verifying the token
const verify = jwt.verify(token, JWT_SECRET);
if (verify) {
const id = verify.user.id;
// checking whether the user is present or not in the database
const check = await User.findById(id);
if (check) {
// saving the user id the requests, we will understand in image
// routes why do we need it.
req.id = id;
next();
} else {
res.status(404).json({});
}
}
}
} catch (error) {
res.status(403).json({});
}
};
module.exports = { authMiddleware };
Our backend server is almost ready, the only thing remaining is creating an image route.
- Using Cloudinary and Multer
As we discussed earlier, we are going to save the image on Cloudinary, and the public link on the MongoDB database.
So, we have to install the cloudinary
package. Also, we are going to use another library called multer
for checking file uploading is an image. There are many use cases of multer
but we are using it only for checking whether the uploaded file is an image.
We have to log on to cloudinary.com
, and then go to the dashboard where you will see the API key, cloud name, and other necessary things for connecting it using node.js
Make a config.js file in the backend folder, and save these keys into that file.
const CLOUD_NAME = "cloudname";
const API_KEY = "apikey";
const API_SECRET = "secretapi";
module.exports = { CLOUD_NAME, API_KEY, API_SECRET };
Now we have to connect the cloudinary
from our backend and have to write login for multer
.
Create a utils
folder in the backend
, and create two files named cloudinary.js
and multer.js
.
cloudinary.js
looks like this:
const { CLOUD_NAME, API_KEY, API_SECRET } = require("../config");
const cloudinary = require("cloudinary").v2;
cloudinary.config({
cloud_name: CLOUD_NAME,
api_key: API_KEY,
api_secret: API_SECRET,
});
module.exports = cloudinary;
multer.js
looks like this:
const multer = require("multer");
const path = require("path");
// for checking uploaded file is image
module.exports = multer({
storage: multer.diskStorage({}),
fileFilter: (req, file, cb) => {
let ext = path.extname(file.originalname);
if (ext !== ".jpg" && ext !== ".jpeg" && ext !== ".png") {
cb(new Error("Unsupported file type!"), false);
return;
}
cb(null, true);
},
});
Now, let's create our most awaited image route.
In the image.js
file, we will have two routes, one for uploading and one for getting the image details(i.e, public link, image id, etc)
image.js
looks like this:
const { Router } = require("express");
const router = Router();
const cloudinary = require("../utils/cloudinary");
const upload = require("../utils/multer");
const { Image } = require("../db/index");
const { authMiddleware } = require("../middleware/authMiddleware");
// getting all the images uploaded by an user
router.get("/getImages", authMiddleware, async (req, res) => {
try {
// we had saved user id in the request field with the help of the
// authMiddleware, now we are using it to find the images uploaded
// by that user
const images = await Image.find({ user: req.id });
res.status(200).json({
success: true,
message: "Images received",
images,
});
} catch (error) {
res.status(404).json({
success: false,
message: "Can't find images",
});
}
});
// uploading an image
router.post(
"/upload",
authMiddleware,
upload.single("image"),
async (req, res) => {
try {
// uploading the image on the cloudinary
const result = await cloudinary.uploader.upload(req.file.path);
// creating the image doc
const image = new Image({
user: req.id,
imageName: req.body.name,
imageLink: result.secure_url,
cloudinary_id: result.public_id,
});
// saving it to the database
await image.save();
res.status(200).json({
success: true,
image: image,
});
} catch (error) {
res.status(404).json({
success: false,
message: "bad request",
});
}
}
);
module.exports = router;
Yayyy!!! Our backend has been created successfully. You can test it on Postman or ThunderClient (Vs Code extension).
2. Creating the FrontEnd
For now, we are not going to use transitions, animations, or any other fancy stuff. We will be keeping it simple and sorted. You can add styles if you want to.
I'll be using Bootstrap to save the time from creating the CSS.
In the image-app
folder, create a vite
react app named frontend
.
If you are new to vite
, then open a terminal in the image-app
and run the command npm create vite@latest
then you will be asked the Project name, write frontend
and hit enter, now you have to select a framework select React
then you will be asked to select a variant, select JavaScript
You are ready to go...
Open it in the vs code.
We know React is all about making components and using them. So, we are going to make components.
Create a components
folder in the src
.
Sign In component
Create
SignIn.jsx
incomponents
It will contain a simple form. It will look like this:
import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; const SignIn = () => { const [credentials, setCredentials] = useState({ name: "", email: "", password: "", cpassword: "", }); // we will use useNavigate for navigating from one page to another. let navigate = useNavigate(); const handleSubmit = async (e) => { // logic for signIn }; const onChange = (e) => { setCredentials({ ...credentials, [e.target.name]: e.target.value }); }; return ( <div className="container my-3 bg-dark text-white rounded p-5"> <h2>Create An Account</h2> <form onSubmit={handleSubmit}> <div className="mb-3"> <label htmlFor="firstName" className="form-label"> firstName </label> <input type="text" className="form-control" id="firstName" name="firstName" onChange={onChange} aria-describedby="firstName" /> </div> <div className="mb-3"> <label htmlFor="lastName" className="form-label"> lastName </label> <input type="text" className="form-control" id="lastName" name="lastName" onChange={onChange} aria-describedby="lastName" /> </div> <div className="mb-3"> <label htmlFor="email" className="form-label"> Email address </label> <input type="email" className="form-control" id="email" name="email" onChange={onChange} aria-describedby="emailHelp" /> </div> <div className="mb-3"> <label htmlFor="password" className="form-label"> Password </label> <input type="password" className="form-control" name="password" id="password" onChange={onChange} minLength={5} required /> </div> <div className="mb-3"> <label htmlFor="cpassword" className="form-label"> Confirm Password </label> <input type="password" className="form-control" id="cpassword" name="cpassword" onChange={onChange} minLength={5} required /> </div> <button type="submit" className="btn btn-primary"> Submit </button> </form> </div> ); }; export default SignIn;
Log In component
Create
Login.jsx
incomponents
It will also contain a simple form. You can write your own logic such that you can use one form for both sign-in and log-in.
Login.jsx
looks like this:import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; const Login = () => { const [credentials, setCredentials] = useState({ email: "", password: "", }); let navigate = useNavigate(); const handleSubmit = async (e) => { // logic for login }; const onChange = (e) => { setCredentials({ ...credentials, [e.target.name]: e.target.value }); }; return ( <div className="container my-3 bg-dark text-white rounded p-5"> <h2>Login to continue to myNotes</h2> <form onSubmit={handleSubmit}> <div className="mb-3"> <label htmlFor="email" className="form-label"> Email address </label> <input type="email" className="form-control" id="email" name="email" value={credentials.email} onChange={onChange} aria-describedby="emailHelp" /> </div> <div className="mb-3"> <label htmlFor="password" className="form-label"> Password </label> <input type="password" className="form-control" name="password" value={credentials.password} onChange={onChange} id="password" /> </div> <button type="submit" className="btn btn-primary"> Submit </button> </form> </div> ); }; export default Login;
Navbar component
Create
Navbar.jsx
incomponents
We will create a navbar here, in which we will have Login, SignIn, and Logout buttons.
import React from "react"; import { Link, useNavigate } from "react-router-dom"; const Navbar = () => { let navigate = useNavigate(); const handleLogout = () => { // logic for logging out }; return ( <nav className="navbar navbar-expand-lg navbar-dark bg-dark"> <div className="container-fluid"> <Link className="navbar-brand" to="/"> myNotes </Link> <button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation" > <span className="navbar-toggler-icon"></span> </button> <div className="collapse navbar-collapse" id="navbarSupportedContent"> <ul className="navbar-nav me-auto mb-2 mb-lg-0"> <li className="nav-item"> <Link className="nav-link" aria-current="page" to="/"> Home </Link> </li> </ul> <form className="d-flex" role="search"> <Link className="btn btn-primary me-1" to="/login" role="button"> Login </Link> <Link className="btn btn-primary mx-1" to="/signin" role="button"> SignUp </Link> </form> <button onClick={handleLogout} className="btn btn-primary"> Log Out </button> </div> </div> </nav> ); }; export default Navbar;
Image Component
Create ImageUpload.jsx in
components
Here we will create a form for uploading images, and at the end, we will write logic for retrieving them from the backend.
import React, { useEffect, useState } from "react"; const ImageUpload = () => { const [image, setImage] = useState(null); const [name, setName] = useState(""); const [imageArray, setImageArray] = useState([]); const [loading, setLoading] = useState(false); const handleFileChange = (e) => { setImage(e.target.files[0]); }; const handleNameChange = (e) => { setName(e.target.value); }; const fetchImages = async () => { //logic for retrieving image from the backend }; useEffect(() => { // some logic for fetching images on the initial render }, []); const handleSubmit = async (e) => { // logic for uploading image on the backend }; return ( <> <div className="container my-3 bg-dark text-white rounded p-5"> <form onSubmit={handleSubmit}> <div className="mb-3"> <label htmlFor="name" className="form-label"> Name </label> <input type="text" className="form-control" id="name" name="name" onChange={handleNameChange} aria-describedby="name" /> </div> <div className="mb-3"> <label htmlFor="image" className="form-label"> Upload Image </label> <input type="file" className="form-control" id="image" name="image" accept="image/png,image/jpg,image/jpeg" onChange={handleFileChange} /> </div> <button type="submit" className="btn btn-primary"> Submit </button> </form> </div> <div className="row my-3"> <h2 className=" text-white">Your Images</h2> <div className="container mx-2 text-white"> {imageArray.length === 0 && "No Notes to display"} </div> {loading ? ( <div className="text-white">Loading...</div> ) : ( imageArray.map((image) => ( <div key={image._id} className="col-md-4"> <div className="card my-3 bg-secondary text-white rounded" style={{ width: "18rem" }} > <img src={image.imageLink} style={{ objectFit: "cover", height: "18rem" }} className="card-img-top" alt="..." /> <div className="card-body"> <p className="card-text">{image.imageName}</p> </div> </div> </div> )) )} </div> </> ); }; export default ImageUpload;
The last thing remaining is establishing the routes on the frontend, for navigating the different components. For that, we have to edit
App.jsx
file. For creating routes we have to importBrowserRouter
,Routes
,Route
fromreact-router-dom
.Run
npm i react-router-dom
to install the package.import "./App.css"; import Login from "./components/Login"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import SignIn from "./components/SignIn"; import Navbar from "./components/Navbar"; import ImageUpload from "./components/ImageUpload"; function App() { return ( <> <Router> <Navbar /> <div className="container"> <Routes> <Route path="/" element={<ImageUpload />} /> <Route path="/signIn" element={<SignIn />} /> <Route path="/login" element={<Login />} /> </Routes> </div> </Router> </> ); } export default App;
3. Integrating the BackEnd with the FrontEnd
Our web app is almost complete, we have to integrate the backend with the frontend only.
To send the requests or to receive the responses from the backend, we use fetch
or axios
. For using axios
we have to install it, using npm i axios
command. fetch
is a modern JavaScript API
for making HTTP
requests. We will use fetch
in our project.
Sign-in logic
Open the
SignIn.jsx
file.sign-in request is post request, so while using
fetch
API, we have to tell what the method is ( post, put, delete, etc.).When we send the request to the backend it will return a token, we will save that token in the localStorage.
edit the
handleSubmit
function that we defined earlier in the file.const handleSubmit = async (e) => { e.preventDefault(); const { firstName, lastName, email, password } = credentials; const response= await fetch("http://localhost:3000/api/v1/user/signUp", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ firstName, lastName, email, password }), }); const json = await response.json(); console.log(json); if (json.success) { // save the auth token and redirect localStorage.setItem("authorization", json.authtoken); navigate("/"); } else { navigate("/signIn"); } };
Log-in logic
Open the
Login.jsx
file.A log-in request is also a post request, so we will do the same as we did for the sign-in logic.
edit the
handleSubmit
function in theLogin.jsx
fileconst handleSubmit = async (e) => { e.preventDefault(); const response = await fetch("http://localhost:3000/api/v1/user/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email: credentials.email, password: credentials.password, }), }); const json = await response.json(); console.log(json); if (json.success) { localStorage.setItem("authorization", json.token); navigate("/"); } else { navigate("/login"); } };
Image Uploading and fetching
This is a bit tricky, but very easy if we understand it once.
Let's start with the uploading logic, as we were sending the inputs for sign-in and log-in requests in the form of
JSON
.JSON
is an excellent format for sending unstructured data but it can't handle file uploads and that's where theFormData
comes into action.FormData
allows us to send different types of files. So, what are we waiting for? Let's code...Edit the
handleSubmit
function in theImageUpload.jsx
const handleSubmit = async (e) => { console.log(e); e.preventDefault(); try { setLoading(true); // create FormData() instance const formData = new FormData(); // adding values to the formData using append method formData.append("image", image); formData.append("name", name); const response = await fetch( "http://localhost:3000/api/v1/image/upload", { method: "POST", body: formData, headers: { Authorization: `Bearer ${localStorage.getItem("authorization")}`, }, } ); const json = await response.json(); setImageArray(imageArray.concat(json.image)); // resetting the form after submitting e.target[1].value = null; e.target[0].value = ""; setImage(null); setName(""); setLoading(false); } catch (error) { console.log(error); } };
Image uploading is done, now we have to work on fetching the images from the server.
When a user is directed to this route, that page must fetch the image on its first rendering and
useEffect
hook is going to help us.Edit the
fetchImages
function and write the logic for theuseEffect
, we are going to usefetchImages
function inside theuseEffect
hook.const fetchImages = async () => { const response = await fetch( "http://localhost:3000/api/v1/image/getImages", { method: "GET", headers: { Authorization:`Bearer ${localStorage.getItem("authorization")}`, }, } ); const json = await response.json(); if (json.success) { setImageArray(json.images); } };
useEffect(() => { // if user is logged in then fetch the images otherwise // redirect the user to the login page if (localStorage.getItem("authorization")) { fetchImages(); } else { navigate("/login"); } }, []);
Fixing the Navbar
Open the
Navbar.jsx
file.In the Navbar component, we have to write the logic for the logout and it is the easiest of all the logic we have implemented till now. We have to just remove the token from the
localStorage
.Edit the
handleLogout
Functionconst handleLogout = () => { localStorage.removeItem("authorization"); navigate("/login"); };
The last thing remaining in the Navbar component is to show and hide the sign-in, log-in, and log-out buttons.
{ !localStorage.getItem("authorization") ? ( <form className="d-flex" role="search"> <Link className="btn btn-primary me-1" to="/login" role="button"> Login </Link> <Link className="btn btn-primary mx-1" to="/signin" role="button"> SignUp </Link> </form> ) : ( <button onClick={handleLogout} className="btn btn-primary"> Log Out </button> ) }
That's it!!!
Our Full-stack application has been made and fully working. The only task on your end is to deploy it, or you can wait for me until I write a blog for that ๐๐๐.
Thank You, and make sure to give feedback and like.