Close Menu
    DevStackTipsDevStackTips
    • Home
    • News & Updates
      1. Tech & Work
      2. View All

      CodeSOD: A Unique Way to Primary Key

      July 22, 2025

      BrowserStack launches Figma plugin for detecting accessibility issues in design phase

      July 22, 2025

      Parasoft brings agentic AI to service virtualization in latest release

      July 22, 2025

      Node.js vs. Python for Backend: 7 Reasons C-Level Leaders Choose Node.js Talent

      July 21, 2025

      The best CRM software with email marketing in 2025: Expert tested and reviewed

      July 22, 2025

      This multi-port car charger can power 4 gadgets at once – and it’s surprisingly cheap

      July 22, 2025

      I’m a wearables editor and here are the 7 Pixel Watch 4 rumors I’m most curious about

      July 22, 2025

      8 ways I quickly leveled up my Linux skills – and you can too

      July 22, 2025
    • Development
      1. Algorithms & Data Structures
      2. Artificial Intelligence
      3. Back-End Development
      4. Databases
      5. Front-End Development
      6. Libraries & Frameworks
      7. Machine Learning
      8. Security
      9. Software Engineering
      10. Tools & IDEs
      11. Web Design
      12. Web Development
      13. Web Security
      14. Programming Languages
        • PHP
        • JavaScript
      Featured

      The Intersection of Agile and Accessibility – A Series on Designing for Everyone

      July 22, 2025
      Recent

      The Intersection of Agile and Accessibility – A Series on Designing for Everyone

      July 22, 2025

      Zero Trust & Cybersecurity Mesh: Your Org’s Survival Guide

      July 22, 2025

      Execute Ping Commands and Get Back Structured Data in PHP

      July 22, 2025
    • Operating Systems
      1. Windows
      2. Linux
      3. macOS
      Featured

      A Tomb Raider composer has been jailed — His legacy overshadowed by $75k+ in loan fraud

      July 22, 2025
      Recent

      A Tomb Raider composer has been jailed — His legacy overshadowed by $75k+ in loan fraud

      July 22, 2025

      “I don’t think I changed his mind” — NVIDIA CEO comments on H20 AI GPU sales resuming in China following a meeting with President Trump

      July 22, 2025

      Galaxy Z Fold 7 review: Six years later — Samsung finally cracks the foldable code

      July 22, 2025
    • Learning Resources
      • Books
      • Cheatsheets
      • Tutorials & Guides
    Home»Development»How to Build Production-Ready Full Stack Apps with the MERN Stack

    How to Build Production-Ready Full Stack Apps with the MERN Stack

    July 7, 2025

    As developers, we’re always looking for more efficient tools. The MERN stack (MongoDB, Express.js, React, and Node.js) stands out for its JavaScript-centric nature, offering a unified language across the entire application.

    In this guide, you’ll build a complete Task Manager app with user authentication, protected routes, and full CRUD functionality, built with React on the frontend and Express/MongoDB on the backend.

    This article will serve as your hands-on, code-first guide to building, securing, and deploying a MERN application, drawing from my own practical experience. Every section has code you can run, and I’ll give concise explanations along the way.

    It doesn’t matter if you’re just getting started with MERN or looking to level up your architecture and production deployment knowledge – this article is designed to get you from zero to production with confidence.

    Table of Contents

    • Prerequisites

      • Tools & Tech Stack

      • Skills & Setup

    • Project Setup: Laying the Groundwork

      • Project Structure

      • Code Quality: Linting and Formatting

    • Testing: Ensuring Robustness

      • Backend Testing (Node.js/Express.js)

      • Frontend Testing (React Testing Library + Cypress)

    • How to Build the Task Manager

      • Backend Implementation (Node.js/Express.js)

      • Frontend Implementation (React)

    • Deployment: From Localhost to Live

      • Backend Deployment (Node.js/Express.js)

      • Frontend Deployment (React)

      • Database Deployment (MongoDB Atlas)

      • 1. .env Configuration Example

      • 2. Connect to MongoDB in app.js

      • Other Deployment Options

    • Security Best Practices: Fortifying Your Application

      • Setup Input Validation and Sanitization

      • Add Authentication and Authorization

      • Implement Rate Limiting

      • Setup CORS Configuration (Cross-Origin Resource Sharing)

      • Use Environment Variables

    • Monitoring and Logging with Winston and Morgan

      • Frontend Error Monitoring (Sentry)
    • Conclusion

    Prerequisites

    Before jumping in the project, here’s what you’ll need to get the most out of this tutorial:

    Tools & Tech Stack

    You’ll be using the following technologies throughout the project:

    • Node.js & npm – Backend runtime and package manager

    • Express.js – Web framework for Node

    • MongoDB Atlas – Cloud-hosted NoSQL database

    • Mongoose – ODM for MongoDB

    • React – Frontend UI library

    • React Router – For client-side routing

    • Axios – For making API requests

    • Jest & Supertest – For backend tests

    • React Testing Library & Cypress – For Frontend unit and E2E tests

    • ESLint + Prettier – For code formatting, linting

    • Husky – To setup pre-commit hooks

    • Helmet, Joi, express-rate-limit, cors – For security, validation, and best practices

    • PM2 & NGINX – For backend deployment

    • Sentry – For error monitoring

    Skills & Setup

    • Basic knowledge of JavaScript, React, and Node.js

    • Familiarity with REST APIs and HTTP request/response flows

    • Git and a GitHub account for version control

    • A free MongoDB Atlas account

    • Node.js and npm installed locally (Node 18+ recommended)

    Project Setup: Laying the Groundwork

    A well-structured project is crucial for maintainability. We’ll adopt a clear separation between the front end and the back end here.

    Project Structure

    This structure clearly separates the React front end (client/) from the Node.js/Express.js back end (server/), promoting modularity and easier management.

    my-mern-app/                # Root folder
    ├── client/                 # React frontend
    │   ├── public/
    │   ├── src/
    │   │   ├── components/
    │   │   ├── pages/
    │   │   ├── App.js
    │   │   └── index.js
    │   └── package.json
    ├── server/                 # Node.js/Express.js backend
    │   ├── config/
    │   ├── controllers/
    │   ├── models/
    │   ├── routes/
    │   ├── services/
    │   ├── app.js
    │   └── package.json
    

    Code Quality: Linting and Formatting

    Consistency is key when you’re building a production-grad application like this one. We’ll use ESLint with Airbnb style and Prettier for automated code quality and formatting.

    To install these tools, run this in your terminal:

    npm install --save-dev eslint prettier eslint-config-airbnb-base eslint-plugin-prettier
    

    And here are some setups with their recommended configurations:

    This configuration sets up ESLint for a Node.js project using the Airbnb and Prettier style guides, with custom rules to relax strict linting constraints like allowing console.log and disabling mandatory function names.

    .eslintrc.js (server-side example)

    module.exports = {
    
      env: {
    
        node: true,
    
        commonjs: true,
    
        es2021: true,
    
      },
    
      extends: ["airbnb-base", "prettier"],
    
      plugins: ["prettier"],
    
      parserOptions: {
    
        ecmaVersion: 12,
    
      },
    
      rules: {
    
        "prettier/prettier": "error",
    
        "no-console": "off",
    
        "func-names": "off",
    
        "no-process-exit": "off",
    
        "class-methods-use-this": "off",
    
        "import/no-extraneous-dependencies": "off",
    
      },
    
    };
    

    .prettierrc

    This config enforces consistent formatting: add semicolons, use trailing commas where valid, and prefer single quotes for strings.

    {
    
      "semi": true,
    
      "trailingComma": "all",
    
      "singleQuote": true
    
    }
    

    Version Control: Git Essentials

    Git is indispensable. You can use feature branches and pull requests for collaborative development, making it easier to work on large projects with coworkers. Consider using Husky for pre-commit hooks to enforce linting and testing.

    Install Husky:

    Install Husky to easily manage Git hooks, allowing you to automate tasks like linting and testing before commits.

    npm install husky --save-dev
    

    package.json (add script)

    This package.json file sets up a Node.js project named my-mern-app, and configures a prepare script to install Git hooks using Husky (v7). It’s ready for adding pre-commit automation, such as linting or testing.

    {
    
      "name": "my-mern-app",
    
      "version": "1.0.0",
    
      "description": "",
    
      "main": "index.js",
    
      "scripts": {
    
        "prepare": "husky install"
    
      },
    
      "keywords": [],
    
      "author": "",
    
      "license": "ISC",
    
      "devDependencies": {
    
        "husky": "^7.0.0"
    
      }
    
    }
    

    Create a pre-commit hook

    The below command sets up a pre-commit hook that automatically runs your tests and linter before each commit, ensuring code quality and preventing errors from entering your codebase.

    npx husky add .husky/pre-commit "npm test && npm run lint"
    

    Testing: Ensuring Robustness

    Automated testing is vital. We’ll cover unit, integration, and end-to-end testing in this guide.

    Backend Testing (Node.js/Express.js)

    You’ll use Jest for unit testing and Supertest for API integration tests.

    Install them like this:
    npm install --save-dev jest supertest
    

    You’ll use Jest to write unit tests for your JavaScript code and Supertest to test HTTP requests against your Express.js API.

    Example Test (server/tests/auth.test.js):

    This test suite uses Supertest to simulate API calls for user registration and login, asserting that the responses have the expected status codes and properties.

    const request = require('supertest');
    
    const app = require('../app'); // Your Express app instance
    
    describe('Auth API', () => {
    
      it('should register a new user', async () => {
    
        const res = await request(app)
    
          .post('/api/auth/register')
    
          .send({
    
            username: 'testuser',
    
            email: 'test@example.com',
    
            password: 'password123',
    
          });
    
        expect(res.statusCode).toEqual(201);
    
        expect(res.body).toHaveProperty('_id');
    
      });
    
    
      it('should login an existing user', async () => {
    
        const res = await request(app)
    
          .post('/api/auth/login')
    
          .send({
    
            email: 'test@example.com',
    
            password: 'password123',
    
          });
    
        expect(res.statusCode).toEqual(200);
    
        expect(res.headers['set-cookie']).toBeDefined();
    
      });
    
    });
    

    Frontend Testing (React Testing Library + Cypress)

    You’ll use Jest and the React Testing Library for unit/integration tests, and Cypress for E2E tests.

    You can install these like this:
    npm install --save-dev @testing-library/react @testing-library/jest-dom jest cypress
    

    React Testing Library will help you test your React components, and Cypress will provide comprehensive end-to-end testing of your frontend application.

    Example Component Test (client/src/components/Button.test.js):

    This unit test uses the React Testing Library to render a Button component and verifies that the specified text content is present in the rendered output.

    import React from 'react';
    
    import { render, screen } from '@testing-library/react';
    
    import Button from './Button';
    
    
    test('renders button with text', () => {
    
      render(<Button>Click Me</Button>);
    
      const buttonElement = screen.getByText(/Click Me/i);
    
      expect(buttonElement).toBeInTheDocument();
    
    });
    

    The following Cypress test simulates a complete user authentication flow, from registration to login and logout, asserting expected URL changes and page content.

    Example E2E Test (cypress/e2e/auth.cy.js)
    
    describe('Authentication Flow', () => {
    
      it('should allow a user to register and login', () => {
    
        cy.visit('/register');
    
        cy.get('input[name="username"]').type('e2euser');
    
        cy.get('input[name="email"]').type('e2e@example.com');
    
        cy.get('input[name="password"]').type('password123');
    
        cy.get('button[type="submit"]').click();
    
        cy.url().should('include', '/dashboard');
    
        cy.contains('Welcome, e2euser');
    
        cy.get('button').contains('Logout').click();
    
        cy.url().should('include', '/login');
    
        cy.get('input[name="email"]').type('e2e@example.com');
    
        cy.get('input[name="password"]').type('password123');
    
        cy.get('button[type="submit"]').click();
    
        cy.url().should('include', '/dashboard');
    
      });
    
    });
    

    How to Build the Task Manager

    We’ll build a simple Task Manager with user authentication and CRUD operations for tasks so you can see how the whole thing comes together.

    Backend Implementation (Node.js/Express.js)

    Dependencies

    Start by installing our core backend libraries: Express for routing, Mongoose for MongoDB interactions, dotenv for environment variables, bcrypt/jsonwebtoken/cookie-parser for secure authentication, and helmet for setting secure HTTP headers:

    npm install express mongoose dotenv bcryptjs jsonwebtoken cookie-parser
    

    server/app.js (Entry Point)

    Next, we’ll set up the first or the main entry point for the backend. This is the main Express.js application file, which configures middleware, establishes a MongoDB connection, and sets up API routes for authentication and task management.

    const express = require('express');
    
    const mongoose = require('mongoose');
    
    const dotenv = require('dotenv');
    
    const cookieParser = require('cookie-parser');
    
    const helmet = require('helmet');
    
    const authRoutes = require('./routes/authRoutes');
    
    const taskRoutes = require('./routes/taskRoutes');
    
    const { notFound, errorHandler } = require('./middleware/errorMiddleware');
    
    dotenv.config();
    
    
    const app = express();
    
    app.use(helmet());
    
    app.use(express.json());
    
    app.use(cookieParser());
    
    
    mongoose.connect(process.env.MONGO_URI)
    
      .then(() => console.log('MongoDB connected!'))
    
      .catch(err => console.error('MongoDB connection error:', err));
    
    
    app.use('/api/auth', authRoutes);
    
    app.use('/api/tasks', taskRoutes);
    
    
    app.get('/', (req, res) => {
    
      res.send('MERN Task Manager API is running!');
    
    });
    
    
    app.use(notFound);
    
    app.use(errorHandler);
    
    
    const PORT = process.env.PORT || 5000;
    
    app.listen(PORT, () => {
    
      console.log(`Server running on port ${PORT}`);
    
    });
    

    server/.env

    To avoid hardcoding secrets, we’ll add a .env file where we can securely store environment variables, such as our database URI and JWT secret. This file stores sensitive environment variables such as your MongoDB connection string, server port, and JWT secret, keeping them secure and separate from your codebase.

    MONGO_URI=your_mongodb_connection_string_here
    
    PORT=5000
    
    JWT_SECRET=supersecretjwtkey
    

    server/models/User.js

    Now, let’s define our User model using MongoDB. This schema includes fields for username, email, and password, with pre-save hooks for password hashing and a method for password comparison.

    const mongoose = require('mongoose');
    
    const bcrypt = require('bcryptjs');
    
    
    const UserSchema = new mongoose.Schema({
    
      username: {
    
        type: String,
    
        required: true,
    
        unique: true,
    
      },
    
      email: {
    
        type: String,
    
        required: true,
    
        unique: true,
    
      },
    
      password: {
    
        type: String,
    
        required: true,
    
      },
    
    });
    
    UserSchema.pre('save', async function (next) {
    
      if (!this.isModified('password')) {
    
        next();
    
      }
    
      const salt = await bcrypt.genSalt(10);
    
      this.password = await bcrypt.hash(this.password, salt);
    
    });
    
    
    UserSchema.methods.matchPassword = async function (enteredPassword) {
    
      return await bcrypt.compare(enteredPassword, this.password);
    
    };
    
    
    module.exports = mongoose.model('User', UserSchema);
    

    server/models/Task.js

    Next, we’ll create the Task model. This schema defines the Task model, which links each task to a user and includes fields for title, description, completion status, and creation timestamp.

    const mongoose = require('mongoose');
    
    
    const TaskSchema = new mongoose.Schema({
    
      user: {
    
        type: mongoose.Schema.Types.ObjectId,
    
        ref: 'User',
    
        required: true,
    
      },
    
      title: {
    
        type: String,
    
        required: true,
    
        trim: true,
    
      },
    
      description: {
    
        type: String,
    
        trim: true,
    
      },
    
      completed: {
    
        type: Boolean,
    
        default: false,
    
      },
    
      createdAt: {
    
        type: Date,
    
        default: Date.now,
    
      },
    
    });
    
    
    module.exports = mongoose.model('Task', TaskSchema);
    

    server/controllers/authController.js

    Let’s build out the authentication controller. This controller handles user authentication flows, including registration, login, logout, and fetching user profiles, using JWTs and secure HTTP-only cookies.

    const User = require('../models/User');
    
    const jwt = require('jsonwebtoken');
    
    const generateToken = (id) => {
    
      return jwt.sign({ id }, process.env.JWT_SECRET, {
    
        expiresIn: '1h',
    
      });
    
    };
    
    exports.registerUser = async (req, res) => {
    
      const { username, email, password } = req.body;
    
      try {
    
        const userExists = await User.findOne({ email });
    
        if (userExists) return res.status(400).json({ message: 'User already exists' });
    
        const user = await User.create({ username, email, password });
    
        if (user) {
    
          const token = generateToken(user._id);
    
          res.cookie('token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: 3600000 });
    
          res.status(201).json({ id: user.id, username: user.username, email: user.email });
    
        } else {
    
          res.status(400).json({ message: 'Invalid user data' });
    
        }
    
      } catch (error) {
    
        res.status(500).json({ message: error.message });
    
      }
    
    };
    
    
    exports.loginUser = async (req, res) => {
    
      const { email, password } = req.body;
    
      try {
    
        const user = await User.findOne({ email });
    
        if (user && (await user.matchPassword(password))) {
    
          const token = generateToken(user._id);
    
          res.cookie('token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: 3600000 });
    
          res.json({ id: user.id, username: user.username, email: user.email });
    
        } else {
    
          res.status(401).json({ message: 'Invalid email or password' });
    
        }
    
      } catch (error) {
    
        res.status(500).json({ message: error.message });
    
      }
    
    };
    
    
    exports.logoutUser = (req, res) => {
    
      res.cookie('token', '', { httpOnly: true, expires: new Date(0) });
    
      res.status(200).json({ message: 'Logged out successfully' });
    
    };
    
    
    exports.getUserProfile = async (req, res) => {
    
      try {
    
        const user = await User.findById(req.user._id).select('-password');
    
        if (user) {
    
          res.json(user);
    
        } else {
    
          res.status(404).json({ message: 'User not found' });
    
        }
    
      } catch (error) {
    
        res.status(500).json({ message: error.message });
    
      }
    
    };
    

    server/controllers/taskController.js

    Now it’s time to implement the task controller. This controller provides the logic for fetching, creating, updating, and deleting tasks, ensuring that users can only interact with their tasks.

    const Task = require('../models/Task');
    
    
    exports.getTasks = async (req, res) => {
    
      try {
    
        const tasks = await Task.find({ user: req.user._id });
    
        res.status(200).json(tasks);
    
      } catch (error) {
    
        res.status(500).json({ message: error.message });
    
      }
    
    };
    
    
    exports.createTask = async (req, res) => {
    
      const { title, description } = req.body;
    
      if (!title) return res.status(400).json({ message: 'Please add a title' });
    
      try {
    
        const task = await Task.create({ title, description, user: req.user._id });
    
        res.status(201).json(task);
    
      } catch (error) {
    
        res.status(500).json({ message: error.message });
    
      }
    
    };
    
    
    exports.updateTask = async (req, res) => {
    
      try {
    
        const task = await Task.findById(req.params.id);
    
        if (!task) return res.status(404).json({ message: 'Task not found' });
    
        if (task.user.toString() !== req.user._id.toString()) return res.status(401).json({ message: 'Not authorized' });
    
    
        const updatedTask = await Task.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
    
        res.status(200).json(updatedTask);
    
      } catch (error) {
    
        res.status(500).json({ message: error.message });
    
      }
    
    };
    
    
    exports.deleteTask = async (req, res) => {
    
      try {
    
        const task = await Task.findById(req.params.id);
    
        if (!task) return res.status(404).json({ message: 'Task not found' });
    
        if (task.user.toString() !== req.user._id.toString()) return res.status(401).json({ message: 'Not authorized' });
    
    
        await Task.deleteOne({ _id: req.params.id });
    
        res.status(200).json({ message: 'Task removed' });
    
      } catch (error) {
    
        res.status(500).json({ message: error.message });
    
      }
    
    };
    

    server/middleware/authMiddleware.js

    To protect private routes, we will create a middleware that verifies the JWT from the request’s cookies, ensuring that only authenticated users can access specific endpoints.

    const jwt = require('jsonwebtoken');
    
    const User = require('../models/User');
    
    exports.protect = async (req, res, next) => {
    
      let token;
    
      if (req.cookies.token) {
    
        try {
    
          token = req.cookies.token;
    
          const decoded = jwt.verify(token, process.env.JWT_SECRET);
    
          req.user = await User.findById(decoded.id).select('-password');
    
          next();
    
        } catch (error) {
    
          res.status(401).json({ message: 'Not authorized, token failed' });
    
        }
    
      } else {
    
        res.status(401).json({ message: 'Not authorized, no token' });
    
      }
    
    };
    

    server/middleware/errorMiddleware.js

    To handle errors cleanly across our backend, we’ll add global error-handling middleware that can handle 404 Not Found errors and provide a centralized error-handling mechanism for consistent API error responses.

    exports.notFound = (req, res, next) => {
    
      const error = new Error(`Not Found - ${req.originalUrl}`);
    
      res.status(404);
    
      next(error);
    
    };
    
    
    exports.errorHandler = (err, req, res, next) => {
    
      const statusCode = res.statusCode === 200 ? 500 : res.statusCode;
    
      res.status(statusCode);
    
      res.json({
    
        message: err.message,
    
        stack: process.env.NODE_ENV === 'production' ? null : err.stack,
    
      });
    
    };
    

    server/routes/authRoutes.js

    Next, let’s define our authentication routes. These endpoints enable user authentication and map HTTP methods to their corresponding controller functions.

    const express = require('express');
    
    const { registerUser, loginUser, logoutUser, getUserProfile } = require('../controllers/authController');
    
    const { protect } = require('../middleware/authMiddleware');
    
    
    const router = express.Router();
    
    
    router.post('/register', registerUser);
    
    router.post('/login', loginUser);
    
    router.get('/logout', logoutUser);
    
    router.get('/profile', protect, getUserProfile);
    
    
    module.exports = router;
    

    server/routes/taskRoutes.js

    Now we’ll add the routes for task operations. This file defines the API routes for task management, applying the protect middleware to secure all task-related operations.

    const express = require('express');
    
    const { getTasks, createTask, updateTask, deleteTask } = require('../controllers/taskController');
    
    const { protect } = require('../middleware/authMiddleware');
    
    const router = express.Router();
    
    router.route('/').get(protect, getTasks).post(protect, createTask);
    
    router.route('/:id').put(protect, updateTask).delete(protect, deleteTask);
    
    module.exports = router;
    

    Frontend Implementation (React)

    Dependencies

    Now, you’ll need to initialize a new React project and install your essential libraries: Axios for HTTP requests, React Router for navigation, and React Toastify for displaying notifications.

    npm install axios react-router-dom react-toastify
    

    client/src/index.js

    Let’s start the frontend by setting up the entry point. Here we are rendering the main App component and wrapping it with AuthProvider to provide authentication context globally.

    import React from 'react';
    
    import ReactDOM from 'react-dom/client';
    
    import './index.css';
    
    import App from './App';
    
    import { AuthProvider } from './context/AuthContext';
    
    
    const root = ReactDOM.createRoot(document.getElementById('root'));
    
    root.render(
    
      <React.StrictMode>
    
        <AuthProvider>
    
          <App />
    
        </AuthProvider>
    
      </React.StrictMode>
    
    );
    

    client/src/App.js

    Next, we’ll define our main App component. This sets up the client-side routing for the application, and defines public and private routes, and includes a navigation bar and toast notification system.

    import React from 'react';
    
    import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
    
    import { ToastContainer } from 'react-toastify';
    
    import 'react-toastify/dist/ReactToastify.css';
    
    
    import Navbar from './components/Navbar';
    
    import Register from './pages/Register';
    
    import Login from './pages/Login';
    
    import Dashboard from './pages/Dashboard';
    
    import PrivateRoute from './components/PrivateRoute';
    
    
    function App() {
    
      return (
    
        <Router>
    
          <Navbar />
    
          <ToastContainer />
    
          <div className="container">
    
            <Routes>
    
              <Route path="/register" element={<Register />} />
    
              <Route path="/login" element={<Login />} />
    
              <Route path="/dashboard" element={<PrivateRoute />}>
    
                <Route index element={<Dashboard />} />
    
              </Route>
    
              <Route path="/" element={<h1>Welcome to Task Manager!</h1>} />
    
            </Routes>
    
          </div>
    
        </Router>
    
      );
    
    }
    
    export default App;
    

    client/src/context/AuthContext.js

    We’ll create an authentication context that manages the global authentication state. It provides functions for user login, registration, and logout, and automatically loads user data on component mount.

    import React, { createContext, useState, useEffect } from 'react';
    
    import axios from 'axios';
    
    const AuthContext = createContext();
    
    export const AuthProvider = ({ children }) => {
    
      const [user, setUser] = useState(null);
    
      const [loading, setLoading] = useState(true);
    
    
      useEffect(() => {
    
        const loadUser = async () => {
    
          try {
    
            const res = await axios.get('/api/auth/profile');
    
            setUser(res.data);
    
          } catch (err) {
    
            setUser(null);
    
          } finally {
    
            setLoading(false);
    
          }
    
        };
    
        loadUser();
    
      }, []);
    
    
      const login = async (email, password) => {
    
        try {
    
          const res = await axios.post('/api/auth/login', { email, password });
    
          setUser(res.data);
    
          return true;
    
        } catch (err) {
    
          console.error(err.response.data.message);
    
          return false;
    
        }
    
      };
    
    
      const register = async (username, email, password) => {
    
        try {
    
          const res = await axios.post('/api/auth/register', { username, email, password });
    
          setUser(res.data);
    
          return true;
    
        } catch (err) {
    
          console.error(err.response.data.message);
    
          return false;
    
        }
    
      };
    
    
      const logout = async () => {
    
        try {
    
          await axios.get('/api/auth/logout');
    
          setUser(null);
    
        } catch (err) {
    
          console.error(err);
    
        }
    
      };
    
    
      return (
    
        <AuthContext.Provider value={{ user, loading, login, register, logout }}>
    
          {children}
    
        </AuthContext.Provider>
    
      );
    
    };
    
    
    export default AuthContext;
    

    client/src/components/Navbar.js

    Here’s a dynamic navigation bar component that dynamically displays links based on the user’s authentication status, showing either login/register options or a welcome message and logout button.

    import React, { useContext } from 'react';
    
    import { Link } from 'react-router-dom';
    
    import AuthContext from '../context/AuthContext';
    
    
    const Navbar = () => {
    
      const { user, logout } = useContext(AuthContext);
    
    
      return (
    
        <nav>
    
          <h1>Task Manager</h1>
    
          <div>
    
            {user ? (
    
              <>
    
                <span>Welcome, {user.username}</span>
    
                <button onClick={logout}>Logout</button>
    
                <Link to="/dashboard">Dashboard</Link>
    
              </>
    
            ) : (
    
              <>
    
                <Link to="/login">Login</Link>
    
                <Link to="/register">Register</Link>
    
              </>
    
            )}
    
          </div>
    
        </nav>
    
      );
    
    };
    
    
    export default Navbar;
    

    client/src/components/PrivateRoute.js

    To protect certain pages, we can create a Private Route component. This will be a guard for private routes, ensuring that only authenticated users can access them and redirecting unauthenticated users to the login page.

    import React, { useContext } from 'react';
    
    import { Navigate, Outlet } from 'react-router-dom';
    
    import AuthContext from '../context/AuthContext';
    
    
    const PrivateRoute = () => {
    
      const { user, loading } = useContext(AuthContext);
    
    
      if (loading) {
    
        return <div>Loading...</div>; // Or a spinner
    
      }
    
    
      return user ? <Outlet /> : <Navigate to="/login" replace />;
    
    };
    
    export default PrivateRoute;
    

    client/src/pages/Register.js

    Now, let’s create the Register component, which provides a user registration form, handles input state and form submission, and displays success or error messages using toast notifications.

    import React, { useState, useContext } from 'react';
    
    import { useNavigate } from 'react-router-dom';
    
    import { toast } from 'react-toastify';
    
    import AuthContext from '../context/AuthContext';
    
    
    const Register = () => {
    
      const [username, setUsername] = useState('');
    
      const [email, setEmail] = useState('');
    
      const [password, setPassword] = useState('');
    
      const { register } = useContext(AuthContext);
    
      const navigate = useNavigate();
    
    
      const handleSubmit = async (e) => {
    
        e.preventDefault();
    
        const success = await register(username, email, password);
    
        if (success) {
    
          toast.success('Registration successful!');
    
          navigate('/dashboard');
    
        } else {
    
          toast.error('Registration failed. Please try again.');
    
        }
    
      };
    
    
      return (
    
        <div>
    
          <h2>Register</h2>
    
          <form onSubmit={handleSubmit}>
    
            <div>
    
              <label>Username:</label>
    
              <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required />
    
            </div>
    
            <div>
    
              <label>Email:</label>
    
              <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
    
            </div>
    
            <div>
    
              <label>Password:</label>
    
              <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
    
            </div>
    
            <button type="submit">Register</button>
    
          </form>
    
        </div>
    
      );
    
    };
    
    
    export default Register;
    

    client/src/pages/Login.js

    Now, for the login form, it works similarly to the register page but logs users into the system instead. This page manages input fields, handles form submissions, and provides feedback via toast notifications.

    import React, { useState, useContext } from 'react';
    
    import { useNavigate } from 'react-router-dom';
    
    import { toast } from 'react-toastify';
    
    import AuthContext from '../context/AuthContext';
    
    
    const Login = () => {
    
      const [email, setEmail] = useState('');
    
      const [password, setPassword] = useState('');
    
      const { login } = useContext(AuthContext);
    
      const navigate = useNavigate();
    
    
      const handleSubmit = async (e) => {
    
        e.preventDefault();
    
        const success = await login(email, password);
    
        if (success) {
    
          toast.success('Login successful!');
    
          navigate('/dashboard');
    
        } else {
    
          toast.error('Login failed. Invalid credentials.');
    
        }
    
      };
    
    
      return (
    
        <div>
    
          <h2>Login</h2>
    
          <form onSubmit={handleSubmit}>
    
            <div>
    
              <label>Email:</label>
    
              <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
    
            </div>
    
            <div>
    
              <label>Password:</label>
    
              <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
    
            </div>
    
            <button type="submit">Login</button>
    
          </form>
    
        </div>
    
      );
    
    };
    
    
    export default Login;
    

    client/src/pages/Dashboard.js

    Finally, we’ll build the Dashboard page. This dashboard component displays a user’s tasks, allowing them to create new tasks, mark tasks as complete or incomplete, and delete tasks, with real-time updates.

    import React, { useState, useEffect, useContext } from 'react';
    
    import axios from 'axios';
    
    import { toast } from 'react-toastify';
    
    import AuthContext from '../context/AuthContext';
    
    
    const Dashboard = () => {
    
      const { user } = useContext(AuthContext);
    
      const [tasks, setTasks] = useState([]);
    
      const [newTaskTitle, setNewTaskTitle] = useState('');
    
      const [newTaskDescription, setNewTaskDescription] = useState('');
    
    
      useEffect(() => {
    
        if (user) {
    
          fetchTasks();
    
        }
    
      }, [user]);
    
    
      const fetchTasks = async () => {
    
        try {
    
          const res = await axios.get('/api/tasks');
    
          setTasks(res.data);
    
        } catch (err) {
    
          toast.error('Failed to fetch tasks.');
    
          console.error(err);
    
        }
    
      };
    
    
      const handleCreateTask = async (e) => {
    
        e.preventDefault();
    
        try {
    
          await axios.post('/api/tasks', { title: newTaskTitle, description: newTaskDescription });
    
          setNewTaskTitle('');
    
          setNewTaskDescription('');
    
          toast.success('Task created successfully!');
    
          fetchTasks();
    
        } catch (err) {
    
          toast.error('Failed to create task.');
    
          console.error(err);
    
        }
    
      };
    
    
      const handleUpdateTask = async (id, completed) => {
    
        try {
    
          await axios.put(`/api/tasks/${id}`, { completed });
    
          toast.success('Task updated successfully!');
    
          fetchTasks();
    
        } catch (err) {
    
          toast.error('Failed to update task.');
    
          console.error(err);
    
        }
    
      };
    
    
      const handleDeleteTask = async (id) => {
    
        try {
    
          await axios.delete(`/api/tasks/${id}`);
    
          toast.success('Task deleted successfully!');
    
          fetchTasks();
    
        } catch (err) {
    
          toast.error('Failed to delete task.');
    
          console.error(err);
    
        }
    
      };
    
    
      return (
    
        <div>
    
          <h2>Welcome, {user ? user.username : 'Guest'}!</h2>
    
          <h3>Your Tasks</h3>
    
          <form onSubmit={handleCreateTask}>
    
            <input
    
              type="text"
    
              placeholder="New Task Title"
    
              value={newTaskTitle}
    
              onChange={(e) => setNewTaskTitle(e.target.value)}
    
              required
    
            />
    
            <input
    
              type="text"
    
              placeholder="Description (optional)"
    
              value={newTaskDescription}
    
              onChange={(e) => setNewTaskDescription(e.target.value)}
    
            />
    
            <button type="submit">Add Task</button>
    
          </form>
    
          <ul>
    
            {tasks.map((task) => (
    
              <li key={task._id}>
    
                <span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
    
                  {task.title}: {task.description}
    
                </span>
    
                <button onClick={() => handleUpdateTask(task._id, !task.completed)}>
    
                  {task.completed ? 'Mark Incomplete' : 'Mark Complete'}
    
                </button>
    
                <button onClick={() => handleDeleteTask(task._id)}>Delete</button>
    
              </li>
    
            ))}
    
          </ul>
    
        </div>
    
      );
    
    };
    
    
    export default Dashboard;
    

    Deployment: From Localhost to Live

    Deploying a MERN stack application involves deploying the backend API and the frontend React application separately.

    Let’s talk about why we do it separately. As you have seen from above, in a MERN stack app, the frontend and backend are separate by design. React handles the UI, while Express and Node handle server logic and API calls. Because they serve different roles, you’ll need to deploy them separately.

    The backend runs on a Node.js compatible server, which connects to a database such as MongoDB Atlas. The frontend, once it is built, becomes static files that can be hosted from anywhere, from NGINX to hosting platforms like Netlify or Vercel.

    This separation provides you with flexibility and improved scalability. Let’s walk through how to deploy each part.

    Backend Deployment (Node.js/Express.js)

    For backend deployment, platforms like Heroku, Render, or AWS EC2 are common choices. Here, I’ll outline a general approach for a cloud VM on AWS EC2

    1. Prepare for Production

    To start, set the environment to production and install only the dependencies your app needs to run, optimizing your application’s performance. Skipping devDependencies helps reduce its footprint.

    export NODE_ENV=production
    
    npm install --production
    

    2. Process Manager (PM2)

    Next, we’ll set up a process manager to keep our backend server running reliably. PM2 is a popular tool that handles automatic restarts if your Node.js application crashes, manages multiple app instances, and also helps ensure high availability in production environments.

    npm install -g pm2
    
    pm2 start server/app.js --name mern-api
    
    pm2 save
    
    pm2 startup
    

    3. NGINX as a Reverse Proxy

    Now that our backend is running with PM2, we need a way to handle incoming web traffic. That’s where NGINX comes in. We’ll install NGINX to serve as a high-performance reverse proxy directing incoming web traffic to your Node.js backend and serving static frontend files.

    sudo apt update
    
    sudo apt install nginx
    

    Once NGINX is installed, it’s time to configure it (/etc/nginx/sites-available/default or a new config file). We’ll set it up to forward API requests to the backend and serve the React app, acting as the single entry point. You can update the default configuration file or create a new one:

    # /etc/nginx/sites-available/default
    server {
    
        listen 80;
    
        server_name your_domain_or_ip;
    
    
        location /api/ {
    
            proxy_pass http://localhost:5000;
    
            proxy_http_version 1.1;
    
            proxy_set_header Upgrade $http_upgrade;
    
            proxy_set_header Connection 'upgrade';
    
            proxy_set_header Host $host;
    
            proxy_cache_bypass $http_upgrade;
    
        }
    
    
        location / {
    
            root /var/www/my-mern-app/client/build; # Path to your React build folder
    
            try_files $uri /index.html;
    
        }
    
    }
    

    With the NGINX configuration created, we’ll enable it and restart the service to apply the changes, making your application go live:

    sudo ln -s /etc/nginx/sites-available/default /etc/nginx/sites-enabled/
    
    sudo systemctl restart nginx
    

    4. HTTPS with Certbot (Let’s Encrypt)

    To secure your app with HTTPS, we can install Certbot and use it to automatically obtain and configure a free SSL/TLS certificate from Let’s Encrypt, enabling secure HTTPS connections for your domain.

    sudo snap install --classic certbot
    
    sudo certbot --nginx -d your_domain_or_ip
    

    Frontend Deployment (React)

    With the backend deployed, let’s move to the frontend. For the React frontend, we’ll build the application and serve the static files via NGINX (as shown above) or a dedicated static site hosted on platforms like Netlify, Vercel, or AWS S3 + CloudFront.

    Build the React App

    This command compiles and optimizes your React application into a build folder containing static assets, ready for efficient deployment to any web server or static hosting service.

    cd client
    
    npm run build
    

    Database Deployment (MongoDB Atlas)

    For production, we’ll use a managed MongoDB service like MongoDB Atlas. It handles replication, sharding, and backups, simplifying database management significantly.

    Create a Cluster on MongoDB Atlas

    • Sign up/Log in to MongoDB Atlas.

    • Create a new cluster (choose a cloud provider and region).

    • Set up a database user with appropriate permissions.

    • Configure network access (allow connections from your server’s IP address).

    • Get your connection string and update MONGO_URI in your server/.env file.

    1. .env Configuration Example

    After creating the cluster and user in MongoDB Atlas, you’ll receive a connection string. You need to update your .env file with it

    # server/.env
    MONGO_URI=mongodb+srv://yourUser:yourPassword@cluster0.mongodb.net/yourDBName
    JWT_SECRET=your_secret_jwt_key
    NODE_ENV=production
    

    2. Connect to MongoDB in app.js

    Next, in the server/app.js file, make sure you’re using the connection string from the environment variable:

    const mongoose = require('mongoose');
    const dotenv = require('dotenv');
    dotenv.config();
    
    mongoose.connect(process.env.MONGO_URI)
      .then(() => console.log('MongoDB connected!'))
      .catch((err) => console.error('Connection error:', err));
    

    Other Deployment Options

    While this article drives you through manual deployment with EC2 and NGINX, other platforms can simplify the process:

    • Render, Railway, and Heroku offer easy full-stack deployment with GitHub integration.

    • Vercel and Netlify are ideal for hosting the React frontend.

    • You may consider using Docker to maintain consistent environments across development and production.

    • For CI/CD, Linting, Testing, & Deployment can be automated on every push using tools like GitHub Actions

    There is no right or wrong choice here. Select the setup that best suits your project’s scale, team experience, and desired level of control.

    Security Best Practices: Fortifying Your Application

    Security is paramount. You can implement these best practices to protect your MERN application.

    Setup Input Validation and Sanitization

    Always validate and sanitize input on the server side. You can use libraries like Joi or Zod to make this process easier.

    Example with Joi:

    To validate and sanitize incoming data on the server, we will utilize Joi, a powerful library for defining schemas and enforcing input rules.

    npm install joi
    

    Now that we’ve installed Joi, we will use it to define strict validation rules for user registration and login inputs. This ensures data quality and prevents common injection attacks.

    // server/validators/authValidator.js
    
    const Joi = require('joi');
    
    
    const registerSchema = Joi.object({
    
      username: Joi.string().min(3).max(30).required(),
    
      email: Joi.string().email().required(),
    
      password: Joi.string().min(6).required(),
    
    });
    
    
    const loginSchema = Joi.object({
    
      email: Joi.string().email().required(),
    
      password: Joi.string().required(),
    
    });
    
    
    module.exports = { registerSchema, loginSchema };
    

    Next, we’ll integrate these schemas directly into our authentication controller to automatically validate incoming request bodies against predefined schemas.

    // server/controllers/authController.js (snippet)
    
    const { registerSchema, loginSchema } = require('../validators/authValidator');
    
    
    exports.registerUser = async (req, res) => {
    
      const { error } = registerSchema.validate(req.body);
    
      if (error) return res.status(400).json({ message: error.details[0].message });
    
      // ... rest of the registration logic
    
    };
    
    
    exports.loginUser = async (req, res) => {
    
      const { error } = loginSchema.validate(req.body);
    
      if (error) return res.status(400).json({ message: error.details[0].message });
    
      // ... rest of the login logic
    
    };
    

    Add Authentication and Authorization

    You can use JWTs for authentication and implement middleware for protected routes.

    JWT Implementation (covered in authController.js and authMiddleware.js above)

    Key aspects:

    • HttpOnly Cookies: Store JWTs in HttpOnly cookies to prevent client-side JavaScript access, mitigating XSS attacks.

    • Secure Flag: Use secure: true in production to ensure cookies are only sent over HTTPS.

    These practices ensure that authentication tokens are securely transmitted and stored, protecting against common web vulnerabilities like Cross-Site Scripting (XSS).

    Implement Rate Limiting

    To protect our API from abuse and malicious intent, we will implement basic rate limiting. This helps protect against brute-force login attempts and DDoS attacks.

    Installation

    We will install express-rate-limit package for it

    npm install express-rate-limit
    

    server/app.js (snippet)

    Once it is installed, let’s configure the rate limiter and apply it to all incoming requests. This ensures that no single IP can overwhelm your server with repeated calls. The following middleware limits each IP address to 200 requests within a 15-minute window.

    const rateLimit = require('express-rate-limit');
    
    const limiter = rateLimit({
    
      windowMs: 15 * 60 * 1000, // 15 minutes
    
      max: 200, // Limit each IP to 200 requests per windowMs
    
      message: 'Too many requests from this IP, please try again after 15 minutes',
    
    });
    
    app.use(limiter); // Apply to all requests
    

    Setup CORS Configuration (Cross-Origin Resource Sharing)

    Next, we move our focus to enable secure communication between your frontend and backend. By default, all browsers block cross-origin requests, so we need to configure CORS (Cross-Origin Resource Sharing) to permit the React app to communicate with the Express API.

    Installation

    npm install cors
    

    server/app.js (snippet)

    Once installed, we can configure CORS for our Express application, specifying allowed origins and enabling credential sharing for secure cross-origin requests. Remember to replace the origin with your actual production URL when deploying.

    const cors = require('cors');
    
    app.use(cors({
    
      origin: 'http://localhost:3000', // Replace with your frontend URL in production
    
      credentials: true,
    
    }));
    

    Use Environment Variables

    To keep sensitive information secure and out of your codebase, we will use environment variables. This allows us to efficiently manage secrets, such as database connection strings and JWT keys, without hardcoding them or including them in the source code.

    Create a .env file in your server/ directory:

    .env (example)

    This .env file stores sensitive configuration details like database connection strings and API keys

    MONGO_URI=your_mongodb_connection_string
    
    JWT_SECRET=your_super_secret_jwt_key
    
    NODE_ENV=production
    

    Monitoring and Logging with Winston and Morgan

    Once the application is live, it’s critical to monitor the behavior and catch issues promptly. Monitoring and logging help you measure performance, find bugs, and keep a log of all server activity.

    We’ll use Morgan for logging HTTP requests and Winston for more general-purpose application logging.

    Installation

    We will install Morgan for logging HTTP requests and Winston for comprehensive and customizable application logging.

    npm install morgan winston
    

    server/config/logger.js

    Next, let’s configure Winston to handle our application logs. This will output logs to the console by default, with options to enable file-based logging for errors and general information.

    const winston = require('winston');
    
    const logger = winston.createLogger({
    
      level: 'info',
    
      format: winston.format.combine(
    
        winston.format.timestamp(),
    
        winston.format.json()
    
      ),
    
      transports: [
    
        new winston.transports.Console(),
    
        // new winston.transports.File({ filename: 'error.log', level: 'error' }),
    
        // new winston.transports.File({ filename: 'combined.log', level: 'info' }),
    
      ],
    
    });
    
    module.exports = logger;
    

    server/app.js (snippet)

    With Winston and Morgan set up, now let’s integrate them into our app.js file. We’ll use Morgan for request logging during development and replace standard console.log calls with Winston logs for structured and configurable application logging.

    const morgan = require('morgan');
    
    const logger = require('./config/logger');
    
    if (process.env.NODE_ENV === 'development') {
    
      app.use(morgan('dev'));
    
    }
    
    // Replace console.log with logger.info for database connection
    
    mongoose.connect(process.env.MONGO_URI)
    
      .then(() => logger.info('MongoDB connected!'))
    
      .catch(err => logger.error('MongoDB connection error:', err));
    
    
    // Replace console.log in app.listen
    
    app.listen(PORT, () => {
    
      logger.info(`Server running on port ${PORT}`);
    
    });
    

    Frontend Error Monitoring (Sentry)

    To monitor errors in the frontend, we’ll integrate Sentry. It’s a fantastic tool for tracking exceptions and performance issues in real time. It helps us capture and report client-side errors.

    Installation

    npm install @sentry/react @sentry/tracing
    

    client/src/index.js (snippet)

    After installation, let’s initialize Sentry in the React application so that it can automatically capture errors and performance data. We’ll add this to our index.js file.

    import * as Sentry from '@sentry/react';
    
    import { BrowserTracing } from '@sentry/tracing';
    
    
    Sentry.init({
    
      dsn: "YOUR_SENTRY_DSN", // Replace with your Sentry DSN
    
      integrations: [new BrowserTracing()],
    
      tracesSampleRate: 1.0,
    
      environment: process.env.NODE_ENV,
    
    });
    

    And that’s it! Congratulations on building and deploying a full-stack MERN app.

    Conclusion

    This article provided a code-first walkthrough of building, securing, and deploying a MERN stack application. By focusing on practical code examples and essential configurations, you now have a solid foundation for your MERN projects.

    Remember, continuous learning and adaptation are key in the ever-evolving world of web development. Happy coding!

    Source: freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More 

    Facebook Twitter Reddit Email Copy Link
    Previous ArticleHow to Work with React Forms So They Don’t Break Your Brain
    Next Article The new Surface Pro 12-inch is now the price it should’ve always been — but only because of this limited anti-Prime Day deal

    Related Posts

    Development

    GPT-5 is Coming: Revolutionizing Software Testing

    July 22, 2025
    Development

    Win the Accessibility Game: Combining AI with Human Judgment

    July 22, 2025
    Leave A Reply Cancel Reply

    For security, use of Google's reCAPTCHA service is required which is subject to the Google Privacy Policy and Terms of Use.

    Continue Reading

    CVE-2025-4613 – Google Web Designer Path Traversal Remote Code Execution Vulnerability

    Common Vulnerabilities and Exposures (CVEs)

    CVE-2025-1235 – Cisco Switch Date Overflow Vulnerability

    Common Vulnerabilities and Exposures (CVEs)

    CVE-2025-49490 – Falcon_Linux Kestrel Lapwing_Linux Router Resource Leak Exposure

    Common Vulnerabilities and Exposures (CVEs)

    Malbian is a Linux distribution for malware analysis and reverse engineering

    Linux

    Highlights

    Linux

    Canonical Dropping Bazaar Support from Launchpad

    June 5, 2025

    Canonical is sunsetting Bazaar version control on Launchpad in 2025. Learn about the timeline, migration…

    Distribution Release: Securonis Linux 3.0

    June 12, 2025

    Learn Godot – Course for Beginners in Spanish

    June 4, 2025

    This Xbox controller anti-Prime Day discount is truly a standout — and “audio features sweeten the deal”

    July 8, 2025
    © DevStackTips 2025. All rights reserved.
    • Contact
    • Privacy Policy

    Type above and press Enter to search. Press Esc to cancel.