Ultimate Node.js & Express Backend Development Guide 2025

Shruti SinghShruti Singh
11 min read

Welcome to the definitive guide for building modern backend applications with Node.js and Express in 2025! Whether you're a beginner or an experienced developer looking to refresh your knowledge, this comprehensive tutorial will walk you through setting up a complete backend system with authentication, database integration, and best practices.

Table of Contents

  1. Project Initialization

  2. Express Setup

  3. Environment Variables

  4. CORS Configuration

  5. Database Integration with MongoDB

  6. Models, Routes, and Controllers Structure

  7. User Authentication System

  8. Password Hashing with Mongoose Hooks

  9. JWT Authentication

  10. Middleware for Protected Routes

Project Initialization

Every Node.js project begins with initialization. Let's set up our project structure:

# Check if Node.js is installed
node -v

# Initialize a new project
npm init -y

The -y flag automatically accepts all default settings, creating a package.json file immediately.

ES Modules vs CommonJS

You have two options for importing modules:

  • CommonJS (Traditional): require() syntax

  • ES Modules (Modern): import syntax

To use ES Modules, add this to your package.json:

json{
  "type": "module"
}

Now you can use modern import syntax:

// OLDWay  const express = require('express');

// New way (ES Modules)
import express from 'express';

Express Setup

Express makes building Node.js applications much simpler with its minimalist web framework approach:

npm i express

Create an index.js file with a basic Express server:

javascriptimport express from 'express';
const app = express();
const port = 3000;

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Development Tools

For a smoother development experience, install Nodemon to automatically restart your server when files change:

# Install as a development dependency
npm i -D nodemon

Update your package.json scripts:

json{
  "scripts": {
    "dev": "nodemon index.js",
    "start": "node index.js"
  }
}

Now run your development server with:

npm run dev

Environment Variables

Production applications should never hardcode sensitive information like database credentials or API keys. Instead, use environment variables with the dotenv package:

npm install dotenv

Create a .env file in your project root:

PORT=4000
MONGO_URL=mongodb://localhost:27017/mydatabase
JWT_SECRET=your_secret_key

Import and configure dotenv in your main file:

import express from 'express';
import dotenv from 'dotenv';

// Load environment variables
dotenv.config();

const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

CORS Configuration

Cross-Origin Resource Sharing (CORS) is essential when your frontend and backend are hosted on different domains:

npm install cors

Configure CORS in your Express app:

javascriptimport express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';

dotenv.config();
const app = express();
const port = process.env.PORT || 3000;

// CORS configuration
app.use(cors({
    origin: 'http://localhost:3000', // Your frontend URL
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization']
}));

// Parse JSON request bodies
app.use(express.json());
// Parse URL-encoded request bodies (form data)
app.use(express.urlencoded({ extended: true }));

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Understanding express.urlencoded({ extended: true })

This middleware parses incoming requests with URL-encoded payloads (typically from HTML forms):

  • extended: false: Uses the built-in querystring library that only supports simple key-value pairs

  • extended: true: Uses the more powerful qs library that allows for nested objects and arrays

Example:

  • Form data: name=John&hobbies=reading&hobbies=coding

  • With extended: false: { name: 'John', hobbies: 'coding' } (only keeps last value)

  • With extended: true: { name: 'John', hobbies: ['reading', 'coding'] } (parsed as array)

Database Integration with MongoDB

We'll use Mongoose, an elegant MongoDB object modeling tool:

bashnpm install mongoose

Create a database connection utility:

javascript// utils/db.js
import mongoose from 'mongoose';

const connectDB = () => {
    mongoose
        .connect(process.env.MONGO_URL)
        .then(() => {
            console.log("Connected to MongoDB");
        })
        .catch((err) => {
            console.log("Error connecting to MongoDB:", err);
        });
};

export default connectDB;

Then import and use it in your main file:

javascriptimport express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import connectDB from './utils/db.js';

dotenv.config();
const app = express();
const port = process.env.PORT || 3000;

// Configure middleware
app.use(cors({
    origin: 'http://localhost:3000',
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routes
app.get('/', (req, res) => {
  res.send('Hello World!');
});

// Connect to database
connectDB();

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Models, Routes, and Controllers Structure

A clean folder structure separates concerns and makes your application easier to maintain:

Models

Models define your database schemas:

javascript// models/user.model.js
import mongoose from "mongoose";

const UserSchema = new mongoose.Schema({
    name: String,
    email: String,
    password: String,
    role: {
        type: String,
        enum: ["user", "admin"],
        default: "user"
    },
    isVerified: {
        type: Boolean,
        default: false
    },
    verificationToken: String,
    resetPasswordToken: String,
    resetPasswordExpires: Date
}, { timestamps: true });

const User = mongoose.model('User', UserSchema);
export default User;

Routes

Routes define your API endpoints:

javascript// routes/user.routes.js
import express from "express";
import { registerUser, verifyUser, loginUser, userProfile } from "../controllers/user.controller.js";
import { isLoggedIn } from "../middleware/auth.middleware.js";

const router = express.Router();

router.post("/register", registerUser);
router.get("/verify/:token", verifyUser);
router.post("/login", loginUser);
router.get("/profile", isLoggedIn, userProfile);

export default router;

Controllers

Controllers handle business logic:

javascript// controllers/user.controller.js
import User from "../models/user.model.js";
import crypto from "crypto";
import nodemailer from "nodemailer";

const registerUser = async (req, res) => {
    // Implementation details...
};

export { registerUser };

Update your main file to use these routes:

javascript// index.js
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import connectDB from './utils/db.js';
import userRoutes from './routes/user.routes.js';

dotenv.config();
const app = express();
const port = process.env.PORT || 3000;

// Configure middleware
app.use(cors({
    origin: 'http://localhost:3000',
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// API routes
app.use("/api/v1/users", userRoutes);

// Connect to database
connectDB();

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

User Authentication System

Let's implement a complete user authentication system:

1. User Registration

javascript// controllers/user.controller.js
import crypto from "crypto";
import User from "../models/user.model.js";
import nodemailer from "nodemailer";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";

const registerUser = async (req, res) => {
    // Get data from client
    const { name, email, password } = req.body;

    // Validate required fields
    if (!name || !email || !password) {
        return res.status(400).json({
            message: "All fields are required"
        });
    }

    try {
        // Check if user already exists
        const existingUser = await User.findOne({ email });
        if (existingUser) {
            return res.status(400).json({ 
                message: "User already exists" 
            });
        }

        // Create new user
        const newUser = await User.create({
            name,
            email,
            password, // Will be hashed by pre-save hook
        });

        // Generate verification token
        const token = crypto.randomBytes(32).toString("hex");

        // Save token to database
        newUser.verificationToken = token;
        await newUser.save();

        // Send verification email
        const transporter = nodemailer.createTransport({
            host: process.env.MAIL_HOST,
            port: process.env.MAIL_PORT,
            secure: false,
            auth: {
                user: process.env.MAIL_USER,
                pass: process.env.MAIL_PASSWORD,
            },
        });

        const mailOptions = {
            from: process.env.MAIL_FROM,
            to: newUser.email,
            subject: "Verify Your Email",
            text: `Please click the link to verify your account: ${process.env.BASE_URL}/api/v1/users/verify/${token}`,
            html: `<p>Please click <a href="${process.env.BASE_URL}/api/v1/users/verify/${token}">here</a> to verify your account.</p>`,
        };

        await transporter.sendMail(mailOptions);

        // Return success response
        res.status(201).json({
            success: true,
            message: "User registered successfully. Please check your email to verify your account."
        });

    } catch (error) {
        console.error("Registration error:", error);
        res.status(500).json({
            success: false,
            message: "Registration failed",
            error: error.message
        });
    }
};

export { registerUser };

2. Email Verification

javascript// controllers/user.controller.js
const verifyUser = async (req, res) => {
    // Get token from URL parameter
    const { token } = req.params;

    // Validate token
    if (!token) {
        return res.status(400).json({ 
            message: "Verification token is required" 
        });
    }

    try {
        // Find user with the token
        const user = await User.findOne({ verificationToken: token });

        if (!user) {
            return res.status(400).json({ 
                message: "Invalid verification token" 
            });
        }

        // Update user to verified status
        user.isVerified = true;
        user.verificationToken = undefined; // Remove the token
        await user.save();

        // Return success response
        res.status(200).json({
            success: true,
            message: "Email verification successful. You can now log in."
        });

    } catch (error) {
        console.error("Verification error:", error);
        res.status(500).json({
            success: false,
            message: "Verification failed",
            error: error.message
        });
    }
};

export { registerUser, verifyUser };

3. User Login

javascript// controllers/user.controller.js
const loginUser = async (req, res) => {
    // Get email and password
    const { email, password } = req.body;

    // Validate required fields
    if (!email || !password) {
        return res.status(400).json({
            message: "Email and password are required"
        });
    }

    try {
        // Find user by email
        const user = await User.findOne({ email });

        if (!user) {
            return res.status(400).json({ 
                message: "Invalid email or password" 
            });
        }

        // Compare passwords
        const isMatch = await bcrypt.compare(password, user.password);

        if (!isMatch) {
            return res.status(400).json({ 
                message: "Invalid email or password" 
            });
        }

        // Check if user is verified
        if (!user.isVerified) {
            return res.status(400).json({
                message: "Please verify your email before logging in"
            });
        }

        // Generate JWT token
        const token = jwt.sign(
            { id: user._id },
            process.env.JWT_SECRET,
            { expiresIn: '24h' }
        );

        // Set cookie options
        const cookieOptions = {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            maxAge: 24 * 60 * 60 * 1000 // 24 hours
        };

        // Set cookie with token
        res.cookie("token", token, cookieOptions);

        // Return success response
        res.status(200).json({
            success: true,
            message: "Login successful",
            token,
            user: {
                id: user._id,
                name: user.name,
                email: user.email,
                role: user.role
            }
        });

    } catch (error) {
        console.error("Login error:", error);
        res.status(500).json({
            success: false,
            message: "Login failed",
            error: error.message
        });
    }
};

export { registerUser, verifyUser, loginUser };

Password Hashing with Mongoose Hooks

Mongoose hooks (middleware) allow you to run functions before or after certain operations. Let's use a pre-save hook to hash passwords:

First, install bcryptjs:

bashnpm install bcryptjs

Then update your user model:

javascript// models/user.model.js
import mongoose from "mongoose";
import bcrypt from "bcryptjs";

const UserSchema = new mongoose.Schema({
    name: String,
    email: String,
    password: String,
    role: {
        type: String,
        enum: ["user", "admin"],
        default: "user"
    },
    isVerified: {
        type: Boolean,
        default: false
    },
    verificationToken: String,
    resetPasswordToken: String,
    resetPasswordExpires: Date
}, { timestamps: true });

// Hash password before saving
UserSchema.pre("save", async function(next) {
    // Only hash password if it was modified (or is new)
    if (!this.isModified("password")) return next();

    try {
        // Generate salt with 10 rounds
        const salt = await bcrypt.genSalt(10);
        // Hash password
        this.password = await bcrypt.hash(this.password, salt);
        next();
    } catch (error) {
        next(error);
    }
});

const User = mongoose.model('User', UserSchema);
export default User;

Understanding Mongoose Hooks

Mongoose hooks fall into two main categories:

  1. Pre Hooks (run before an action):

    • pre("save") - Runs before saving a document

    • pre("remove") - Runs before removing a document

    • pre("updateOne") - Runs before updating a document

    • pre("find") - Runs before finding documents

    • pre("validate") - Runs before validating a document

  2. Post Hooks (run after an action):

    • post("save") - Runs after saving a document

    • post("remove") - Runs after removing a document

    • post("updateOne") - Runs after updating a document

    • post("find") - Runs after finding documents

Note: Hashing is a one-way process (irreversible), unlike encryption which is reversible. This is why password hashing is more secure for storing passwords.

JWT Authentication

JSON Web Tokens (JWT) provide a stateless authentication mechanism:

bashnpm install jsonwebtoken cookie-parser

Update your main file to use cookie-parser:

javascriptimport express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import cookieParser from 'cookie-parser';
import connectDB from './utils/db.js';
import userRoutes from './routes/user.routes.js';

dotenv.config();
const app = express();
const port = process.env.PORT || 3000;

// Configure middleware
app.use(cors({
    origin: 'http://localhost:3000',
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());

// API routes
app.use("/api/v1/users", userRoutes);

// Connect to database
connectDB();

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Middleware for Protected Routes

Create authentication middleware to protect routes:

javascript// middleware/auth.middleware.js
import jwt from 'jsonwebtoken';
import User from '../models/user.model.js';

export const isLoggedIn = async (req, res, next) => {
    try {
        // Get token from cookies
        const token = req.cookies?.token;

        if (!token) {
            return res.status(401).json({
                success: false,
                message: "Authentication required. Please log in."
            });
        }

        // Verify token
        const decoded = jwt.verify(token, process.env.JWT_SECRET);

        // Find user by ID
        const user = await User.findById(decoded.id).select('-password');

        if (!user) {
            return res.status(404).json({
                success: false,
                message: "User not found"
            });
        }

        // Attach user to request object
        req.user = user;

        // Proceed to next middleware or route handler
        next();

    } catch (error) {
        console.error("Authentication error:", error);

        if (error.name === 'JsonWebTokenError') {
            return res.status(401).json({
                success: false,
                message: "Invalid token. Please log in again."
            });
        }

        if (error.name === 'TokenExpiredError') {
            return res.status(401).json({
                success: false,
                message: "Token expired. Please log in again."
            });
        }

        res.status(500).json({
            success: false,
            message: "Authentication failed",
            error: error.message
        });
    }
};

Use this middleware to protect routes:

javascript// routes/user.routes.js
import express from "express";
import { registerUser, verifyUser, loginUser, userProfile } from "../controllers/user.controller.js";
import { isLoggedIn } from "../middleware/auth.middleware.js";

const router = express.Router();

router.post("/register", registerUser);
router.get("/verify/:token", verifyUser);
router.post("/login", loginUser);
router.get("/profile", isLoggedIn, userProfile);

export default router;

And implement the user profile controller:

javascript// controllers/user.controller.js
const userProfile = async (req, res) => {
    try {
        // User is already attached to req by isLoggedIn middleware
        res.status(200).json({
            success: true,
            user: req.user
        });
    } catch (error) {
        console.error("Profile error:", error);
        res.status(500).json({
            success: false,
            message: "Failed to fetch profile",
            error: error.message
        });
    }
};

export { registerUser, verifyUser, loginUser, userProfile };

Conclusion

You now have a robust Node.js backend with Express that includes:

  • Project initialization with ES modules

  • Express server configuration

  • Environment variable management

  • CORS setup

  • MongoDB integration with Mongoose

  • A clean MVC architecture

  • User authentication with email verification

  • Password hashing

  • JWT authentication

  • Protected routes with middleware

This foundation enables you to build secure, scalable applications with Node.js and Express in 2025. The modular structure allows for easy extension and maintenance as your application grows.

Remember these key principles:

  1. Keep your code organized with clear separation of concerns

  2. Use environment variables for sensitive information

  3. Hash passwords, never store them in plain text

  4. Use JWT for stateless authentication

  5. Protect your routes with middleware

  6. Implement proper error handling

Happy coding!

0
Subscribe to my newsletter

Read articles from Shruti Singh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shruti Singh
Shruti Singh

Hey, I'm Shruti 👋 I'm a passionate self-taught developer who dove headfirst into web development in 2022 after completing my intermediate education. I specialize in crafting seamless user experiences with React, Next.js, and TypeScript, while continuously expanding my full-stack capabilities. This blog is where I document what I'm learning, building, and improving — in public. What drives me is the thrill of shipping polished products that solve real-world problems and creating intuitive, high-performance web experiences. What you'll find here: Lessons from building full-stack projects with clean UI and smooth UX Deep dives into React, TypeScript, and frontend performance Tips for mastering freelancing and handling clients professionally Honest stories from my journey — mindset shifts, confidence, and growth Exploring emerging technologies and design principles My journey is defined by constant learning, building, and refining—each project pushing my technical boundaries further. I believe great frontend development sits at the intersection of technical excellence and thoughtful user experience, and that's exactly where I aim to excel. If you're learning, freelancing, or trying to get really good at frontend dev — you'll feel right at home here. Let's grow together. ✨