Ultimate Node.js & Express Backend Development Guide 2025

Table of contents
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
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()
syntaxES 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 pairsextended: 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:
Pre Hooks (run before an action):
pre("save")
- Runs before saving a documentpre("remove")
- Runs before removing a documentpre("updateOne")
- Runs before updating a documentpre("find")
- Runs before finding documentspre("validate")
- Runs before validating a document
Post Hooks (run after an action):
post("save")
- Runs after saving a documentpost("remove")
- Runs after removing a documentpost("updateOne")
- Runs after updating a documentpost("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:
Keep your code organized with clear separation of concerns
Use environment variables for sensitive information
Hash passwords, never store them in plain text
Use JWT for stateless authentication
Protect your routes with middleware
Implement proper error handling
Happy coding!
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. ✨