Membangun Backend Modern dengan Node.js dan Express.js
Node.js dan Express.js telah menjadi pilihan populer untuk membangun backend aplikasi web modern. Artikel ini akan memandu Anda membangun REST API yang robust, scalable, dan secure.
Mengapa Node.js dan Express.js?
Keuntungan Node.js
- JavaScript Everywhere: Satu bahasa untuk frontend dan backend
- Non-blocking I/O: Performance yang excellent untuk I/O intensive applications
- NPM Ecosystem: Package manager dengan library terbanyak
- Rapid Development: Fast prototyping dan development cycle
- Scalability: Built-in support untuk microservices architecture
Keuntungan Express.js
- Minimalist: Lightweight dan flexible
- Middleware: Powerful middleware system
- Routing: Robust routing capabilities
- Template Engines: Support untuk berbagai template engines
- Community: Large community dan extensive documentation
Setup Project
Inisialisasi Project
# Buat direktori project
mkdir my-api
cd my-api
# Initialize npm
npm init -y
# Install dependencies
npm install express cors helmet morgan dotenv bcryptjs jsonwebtoken
npm install -D nodemon jest supertest eslint prettier
Package.json Scripts
{
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint src/",
"format": "prettier --write src/"
}
}
Environment Variables
# .env
NODE_ENV=development
PORT=3000
DB_CONNECTION_STRING=mongodb://localhost:27017/myapp
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRES_IN=7d
BCRYPT_ROUNDS=12
Struktur Project
src/
├── controllers/
│ ├── authController.js
│ ├── userController.js
│ └── postController.js
├── middleware/
│ ├── auth.js
│ ├── errorHandler.js
│ └── validation.js
├── models/
│ ├── User.js
│ └── Post.js
├── routes/
│ ├── auth.js
│ ├── users.js
│ └── posts.js
├── utils/
│ ├── database.js
│ ├── logger.js
│ └── helpers.js
├── config/
│ └── database.js
├── tests/
│ ├── auth.test.js
│ └── users.test.js
├── app.js
└── server.js
Basic Express Setup
Server.js
// src/server.js
require("dotenv").config();
const app = require("./app");
const { connectDB } = require("./config/database");
const logger = require("./utils/logger");
const PORT = process.env.PORT || 3000;
// Connect to database
connectDB();
// Start server
const server = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT} in ${process.env.NODE_ENV} mode`);
});
// Handle unhandled promise rejections
process.on("unhandledRejection", (err) => {
logger.error("Unhandled Rejection:", err.message);
server.close(() => {
process.exit(1);
});
});
// Handle uncaught exceptions
process.on("uncaughtException", (err) => {
logger.error("Uncaught Exception:", err.message);
process.exit(1);
});
module.exports = server;
App.js
// src/app.js
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const rateLimit = require("express-rate-limit");
const authRoutes = require("./routes/auth");
const userRoutes = require("./routes/users");
const postRoutes = require("./routes/posts");
const errorHandler = require("./middleware/errorHandler");
const logger = require("./utils/logger");
const app = express();
// Security middleware
app.use(helmet());
app.use(
cors({
origin: process.env.FRONTEND_URL || "http://localhost:3000",
credentials: true,
})
);
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: "Too many requests from this IP, please try again later.",
});
app.use("/api/", limiter);
// Body parsing middleware
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// Logging middleware
if (process.env.NODE_ENV === "development") {
app.use(morgan("dev"));
} else {
app.use(
morgan("combined", {
stream: { write: (message) => logger.info(message.trim()) },
})
);
}
// Health check endpoint
app.get("/health", (req, res) => {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
});
// API routes
app.use("/api/auth", authRoutes);
app.use("/api/users", userRoutes);
app.use("/api/posts", postRoutes);
// 404 handler
app.all("*", (req, res) => {
res.status(404).json({
status: "error",
message: `Route ${req.originalUrl} not found`,
});
});
// Global error handler
app.use(errorHandler);
module.exports = app;
Database Connection
// src/config/database.js
const mongoose = require("mongoose");
const logger = require("../utils/logger");
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.DB_CONNECTION_STRING, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
logger.info(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
logger.error("Database connection error:", error.message);
process.exit(1);
}
};
// Handle connection events
mongoose.connection.on("disconnected", () => {
logger.warn("MongoDB disconnected");
});
mongoose.connection.on("reconnected", () => {
logger.info("MongoDB reconnected");
});
module.exports = { connectDB };
Models
User Model
// src/models/User.js
const mongoose = require("mongoose");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, "Name is required"],
trim: true,
maxlength: [50, "Name cannot be more than 50 characters"],
},
email: {
type: String,
required: [true, "Email is required"],
unique: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, "Please provide a valid email"],
},
password: {
type: String,
required: [true, "Password is required"],
minlength: [6, "Password must be at least 6 characters"],
select: false, // Don't include password in queries by default
},
role: {
type: String,
enum: ["user", "admin"],
default: "user",
},
avatar: {
type: String,
default: null,
},
isActive: {
type: Boolean,
default: true,
},
lastLogin: {
type: Date,
default: null,
},
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
// Index for better query performance
userSchema.index({ email: 1 });
userSchema.index({ createdAt: -1 });
// Virtual for user's posts
userSchema.virtual("posts", {
ref: "Post",
localField: "_id",
foreignField: "author",
});
// Hash password before saving
userSchema.pre("save", async function (next) {
if (!this.isModified("password")) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
// Compare password method
userSchema.methods.comparePassword = async function (candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
// Generate JWT token
userSchema.methods.generateAuthToken = function () {
return jwt.sign(
{ id: this._id, email: this.email, role: this.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
};
// Remove sensitive data from JSON output
userSchema.methods.toJSON = function () {
const user = this.toObject();
delete user.password;
delete user.__v;
return user;
};
module.exports = mongoose.model("User", userSchema);
Post Model
// src/models/Post.js
const mongoose = require("mongoose");
const postSchema = new mongoose.Schema(
{
title: {
type: String,
required: [true, "Title is required"],
trim: true,
maxlength: [100, "Title cannot be more than 100 characters"],
},
content: {
type: String,
required: [true, "Content is required"],
maxlength: [5000, "Content cannot be more than 5000 characters"],
},
excerpt: {
type: String,
maxlength: [200, "Excerpt cannot be more than 200 characters"],
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
tags: [
{
type: String,
trim: true,
lowercase: true,
},
],
status: {
type: String,
enum: ["draft", "published", "archived"],
default: "draft",
},
featuredImage: {
type: String,
default: null,
},
views: {
type: Number,
default: 0,
},
likes: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "User",
},
],
publishedAt: {
type: Date,
default: null,
},
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
// Indexes
postSchema.index({ author: 1, createdAt: -1 });
postSchema.index({ status: 1, publishedAt: -1 });
postSchema.index({ tags: 1 });
postSchema.index({ title: "text", content: "text" });
// Virtual for like count
postSchema.virtual("likeCount").get(function () {
return this.likes.length;
});
// Auto-generate excerpt if not provided
postSchema.pre("save", function (next) {
if (!this.excerpt && this.content) {
this.excerpt = this.content.substring(0, 150) + "...";
}
if (this.status === "published" && !this.publishedAt) {
this.publishedAt = new Date();
}
next();
});
module.exports = mongoose.model("Post", postSchema);
Controllers
Auth Controller
// src/controllers/authController.js
const User = require("../models/User");
const { validationResult } = require("express-validator");
const logger = require("../utils/logger");
class AuthController {
// Register new user
static async register(req, res, next) {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
status: "error",
message: "Validation failed",
errors: errors.array(),
});
}
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({
status: "error",
message: "User with this email already exists",
});
}
// Create new user
const user = new User({ name, email, password });
await user.save();
// Generate token
const token = user.generateAuthToken();
logger.info(`New user registered: ${email}`);
res.status(201).json({
status: "success",
message: "User registered successfully",
data: {
user,
token,
},
});
} catch (error) {
next(error);
}
}
// Login user
static async login(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
status: "error",
message: "Validation failed",
errors: errors.array(),
});
}
const { email, password } = req.body;
// Find user and include password
const user = await User.findOne({ email, isActive: true }).select(
"+password"
);
if (!user) {
return res.status(401).json({
status: "error",
message: "Invalid email or password",
});
}
// Check password
const isPasswordValid = await user.comparePassword(password);
if (!isPasswordValid) {
return res.status(401).json({
status: "error",
message: "Invalid email or password",
});
}
// Update last login
user.lastLogin = new Date();
await user.save();
// Generate token
const token = user.generateAuthToken();
logger.info(`User logged in: ${email}`);
res.json({
status: "success",
message: "Login successful",
data: {
user,
token,
},
});
} catch (error) {
next(error);
}
}
// Get current user
static async getMe(req, res, next) {
try {
const user = await User.findById(req.user.id).populate("posts");
res.json({
status: "success",
data: { user },
});
} catch (error) {
next(error);
}
}
// Update current user
static async updateMe(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
status: "error",
message: "Validation failed",
errors: errors.array(),
});
}
const { name, avatar } = req.body;
const user = await User.findByIdAndUpdate(
req.user.id,
{ name, avatar },
{ new: true, runValidators: true }
);
res.json({
status: "success",
message: "Profile updated successfully",
data: { user },
});
} catch (error) {
next(error);
}
}
// Change password
static async changePassword(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
status: "error",
message: "Validation failed",
errors: errors.array(),
});
}
const { currentPassword, newPassword } = req.body;
const user = await User.findById(req.user.id).select("+password");
// Verify current password
const isCurrentPasswordValid =
await user.comparePassword(currentPassword);
if (!isCurrentPasswordValid) {
return res.status(400).json({
status: "error",
message: "Current password is incorrect",
});
}
// Update password
user.password = newPassword;
await user.save();
logger.info(`Password changed for user: ${user.email}`);
res.json({
status: "success",
message: "Password changed successfully",
});
} catch (error) {
next(error);
}
}
}
module.exports = AuthController;
Middleware
Authentication Middleware
// src/middleware/auth.js
const jwt = require("jsonwebtoken");
const User = require("../models/User");
const auth = async (req, res, next) => {
try {
const token = req.header("Authorization")?.replace("Bearer ", "");
if (!token) {
return res.status(401).json({
status: "error",
message: "Access denied. No token provided.",
});
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.id);
if (!user || !user.isActive) {
return res.status(401).json({
status: "error",
message: "Invalid token or user not found.",
});
}
req.user = user;
next();
} catch (error) {
res.status(401).json({
status: "error",
message: "Invalid token.",
});
}
};
// Admin authorization
const adminAuth = (req, res, next) => {
if (req.user.role !== "admin") {
return res.status(403).json({
status: "error",
message: "Access denied. Admin privileges required.",
});
}
next();
};
module.exports = { auth, adminAuth };
Error Handler
// src/middleware/errorHandler.js
const logger = require("../utils/logger");
const errorHandler = (err, req, res, next) => {
let error = { ...err };
error.message = err.message;
// Log error
logger.error(err.stack);
// Mongoose bad ObjectId
if (err.name === "CastError") {
const message = "Resource not found";
error = { message, statusCode: 404 };
}
// Mongoose duplicate key
if (err.code === 11000) {
const message = "Duplicate field value entered";
error = { message, statusCode: 400 };
}
// Mongoose validation error
if (err.name === "ValidationError") {
const message = Object.values(err.errors)
.map((val) => val.message)
.join(", ");
error = { message, statusCode: 400 };
}
// JWT errors
if (err.name === "JsonWebTokenError") {
const message = "Invalid token";
error = { message, statusCode: 401 };
}
if (err.name === "TokenExpiredError") {
const message = "Token expired";
error = { message, statusCode: 401 };
}
res.status(error.statusCode || 500).json({
status: "error",
message: error.message || "Server Error",
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
});
};
module.exports = errorHandler;
Routes
Auth Routes
// src/routes/auth.js
const express = require("express");
const { body } = require("express-validator");
const AuthController = require("../controllers/authController");
const { auth } = require("../middleware/auth");
const router = express.Router();
// Validation rules
const registerValidation = [
body("name")
.trim()
.isLength({ min: 2, max: 50 })
.withMessage("Name must be between 2 and 50 characters"),
body("email")
.isEmail()
.normalizeEmail()
.withMessage("Please provide a valid email"),
body("password")
.isLength({ min: 6 })
.withMessage("Password must be at least 6 characters")
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage(
"Password must contain at least one lowercase letter, one uppercase letter, and one number"
),
];
const loginValidation = [
body("email")
.isEmail()
.normalizeEmail()
.withMessage("Please provide a valid email"),
body("password").notEmpty().withMessage("Password is required"),
];
// Routes
router.post("/register", registerValidation, AuthController.register);
router.post("/login", loginValidation, AuthController.login);
router.get("/me", auth, AuthController.getMe);
router.put(
"/me",
auth,
[
body("name").optional().trim().isLength({ min: 2, max: 50 }),
body("avatar").optional().isURL(),
],
AuthController.updateMe
);
router.put(
"/change-password",
auth,
[
body("currentPassword")
.notEmpty()
.withMessage("Current password is required"),
body("newPassword")
.isLength({ min: 6 })
.withMessage("New password must be at least 6 characters"),
],
AuthController.changePassword
);
module.exports = router;
Testing
Auth Tests
// src/tests/auth.test.js
const request = require("supertest");
const app = require("../app");
const User = require("../models/User");
describe("Auth Endpoints", () => {
beforeEach(async () => {
await User.deleteMany({});
});
describe("POST /api/auth/register", () => {
it("should register a new user", async () => {
const userData = {
name: "John Doe",
email: "john@example.com",
password: "Password123",
};
const res = await request(app)
.post("/api/auth/register")
.send(userData)
.expect(201);
expect(res.body.status).toBe("success");
expect(res.body.data.user.email).toBe(userData.email);
expect(res.body.data.token).toBeDefined();
});
it("should not register user with invalid email", async () => {
const userData = {
name: "John Doe",
email: "invalid-email",
password: "Password123",
};
const res = await request(app)
.post("/api/auth/register")
.send(userData)
.expect(400);
expect(res.body.status).toBe("error");
});
});
describe("POST /api/auth/login", () => {
beforeEach(async () => {
const user = new User({
name: "John Doe",
email: "john@example.com",
password: "Password123",
});
await user.save();
});
it("should login with valid credentials", async () => {
const res = await request(app)
.post("/api/auth/login")
.send({
email: "john@example.com",
password: "Password123",
})
.expect(200);
expect(res.body.status).toBe("success");
expect(res.body.data.token).toBeDefined();
});
it("should not login with invalid credentials", async () => {
const res = await request(app)
.post("/api/auth/login")
.send({
email: "john@example.com",
password: "wrongpassword",
})
.expect(401);
expect(res.body.status).toBe("error");
});
});
});
Best Practices
1. Security
// Rate limiting
const rateLimit = require("express-rate-limit");
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 5 requests per windowMs
message: "Too many authentication attempts, please try again later.",
standardHeaders: true,
legacyHeaders: false,
});
router.use("/login", authLimiter);
2. Input Validation
// Custom validation
const { body, validationResult } = require("express-validator");
const validateObjectId = (value) => {
if (!mongoose.Types.ObjectId.isValid(value)) {
throw new Error("Invalid ID format");
}
return true;
};
const postValidation = [
body("title").trim().isLength({ min: 1, max: 100 }),
body("content").trim().isLength({ min: 1, max: 5000 }),
body("tags").optional().isArray({ max: 10 }),
body("tags.*").trim().isLength({ min: 1, max: 20 }),
];
3. Error Handling
// Async wrapper
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Usage
router.get(
"/posts",
asyncHandler(async (req, res) => {
const posts = await Post.find();
res.json({ posts });
})
);
4. Logging
// src/utils/logger.js
const winston = require("winston");
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: "api" },
transports: [
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
new winston.transports.File({ filename: "logs/combined.log" }),
],
});
if (process.env.NODE_ENV !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.simple(),
})
);
}
module.exports = logger;
Deployment
Docker
# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
USER node
CMD ["npm", "start"]
PM2 Configuration
// ecosystem.config.js
module.exports = {
apps: [
{
name: "my-api",
script: "src/server.js",
instances: "max",
exec_mode: "cluster",
env: {
NODE_ENV: "production",
PORT: 3000,
},
error_file: "logs/err.log",
out_file: "logs/out.log",
log_file: "logs/combined.log",
time: true,
},
],
};
Kesimpulan
Membangun backend dengan Node.js dan Express.js memerlukan perhatian pada struktur project, security, error handling, dan testing. Dengan mengikuti best practices yang telah dibahas, Anda dapat membangun API yang robust, scalable, dan maintainable.
Kunci sukses adalah konsistensi dalam penerapan patterns, comprehensive testing, dan monitoring yang baik di production environment.



