Building a Production-Ready REST API with Node.js and Express

Building a Production-Ready REST API with Node.js and Express
Building a REST API is easy. Building one that is secure, scalable, maintainable, and ready for production requires a more structured approach.
In this guide, we will build a production-ready backend using:
- Node.js
- Express.js
- TypeScript
- MongoDB
- Redis
- JWT authentication
- Zod validation
- PM2
Project Structure
A clean folder structure makes your application easier to understand and maintain.
```text src/ ├── config/ │ ├── db.ts │ ├── redis.ts │ └── env.ts ├── controllers/ │ ├── auth.controller.ts │ └── blog.controller.ts ├── middleware/ │ ├── auth.middleware.ts │ ├── error.middleware.ts │ └── rateLimit.middleware.ts ├── models/ │ ├── User.ts │ └── Blog.ts ├── routes/ │ ├── auth.routes.ts │ └── blog.routes.ts ├── services/ │ └── blog.service.ts ├── utils/ │ └── ApiError.ts ├── app.ts └── server.ts ```
This structure separates routing, business logic, database models, and configuration.
Setting Up Express
Create the main Express application inside `app.ts`.
```ts import express from "express"; import cors from "cors"; import compression from "compression"; import cookieParser from "cookie-parser";
import authRoutes from "./routes/auth.routes"; import blogRoutes from "./routes/blog.routes"; import errorMiddleware from "./middleware/error.middleware";
const app = express();
app.set("trust proxy", 1);
app.use( cors({ origin: process.env.FRONTEND_URL, credentials: true, }) );
app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(compression());
app.use("/api/v1/auth", authRoutes); app.use("/api/v1/blogs", blogRoutes);
app.use(errorMiddleware);
export default app; ```
The `trust proxy` setting is especially important when your application runs behind Nginx, Cloudflare, or another reverse proxy.
Connecting to MongoDB
Create a reusable MongoDB connection function.
```ts import mongoose from "mongoose";
const connectDB = async (): Promise<void> => { const mongoUrl = process.env.MONGODB_URL;
if (!mongoUrl) { throw new Error("MONGODB_URL is not configured"); }
await mongoose.connect(mongoUrl);
console.log("MongoDB connected"); };
export default connectDB; ```
Always keep database credentials inside environment variables.
```env MONGODB_URL=mongodb+srv://username:password@cluster.mongodb.net/blog ```
Never commit your real environment variables to GitHub.
Connecting to Redis
Redis can be used for caching, sessions, token storage, and rate limiting.
```ts import { createClient } from "redis";
const redisClient = createClient({ url: process.env.REDIS_URL, });
redisClient.on("connect", () => { console.log("Redis connected"); });
redisClient.on("error", (error) => { console.error("Redis error:", error); });
export const connectRedis = async (): Promise<void> => { if (!redisClient.isOpen) { await redisClient.connect(); } };
export default redisClient; ```
For a hosted Redis service such as Upstash, your environment variable may look like this:
```env REDIS_URL=rediss://default:password@your-redis-host:6379 ```
The `rediss://` protocol enables a secure TLS connection.
Starting the Server
Your `server.ts` file should connect external services before accepting requests.
```ts import dotenv from "dotenv";
dotenv.config();
import app from "./app"; import connectDB from "./config/db"; import { connectRedis } from "./config/redis";
const startServer = async (): Promise<void> => { try { await connectRedis(); await connectDB();
const port = Number(process.env.PORT) || 5000;
app.listen(port, "0.0.0.0", () => {
console.log(\`Server is running on port \${port}\`);
});
} catch (error) { console.error("Failed to start server:", error); process.exit(1); } };
startServer(); ```
Connecting services before calling `app.listen()` prevents your API from accepting requests before it is ready.
Creating the Blog Model
A Mongoose schema defines how blog documents are stored.
```ts import mongoose, { Schema } from "mongoose"; import { IBlog } from "../config/Types";
const BlogSchema = new Schema<IBlog>( { title: { type: String, required: true, trim: true, },
slug: {
type: String,
required: true,
unique: true,
index: true,
lowercase: true,
trim: true,
},
content: {
type: String,
required: true,
},
excerpt: {
type: String,
required: true,
},
category: {
type: Schema.Types.ObjectId,
ref: "Category",
required: true,
},
tags: [
{
type: String,
index: true,
},
],
featuredImage: {
type: String,
required: true,
},
seoTitle: {
type: String,
required: true,
},
seoDescription: {
type: String,
required: true,
},
isDeleted: {
type: Boolean,
default: false,
},
isFeatured: {
type: Boolean,
default: false,
},
status: {
type: String,
enum: ["draft", "published"],
default: "draft",
},
views: {
type: Number,
default: 0,
},
readTime: {
type: Number,
},
}, { timestamps: true, } );
const Blog = mongoose.models.Blog<IBlog> || mongoose.model<IBlog>("Blog", BlogSchema);
export default Blog; ```
Creating a Blog Controller
The controller receives the request, validates the input, and creates the blog.
```ts import { Request, Response, NextFunction } from "express"; import slugify from "slugify"; import Blog from "../models/Blog";
export const createBlog = async ( req: Request, res: Response, next: NextFunction ): Promise<void> => { try { const { title, content, excerpt, category, tags, featuredImage, seoTitle, seoDescription, isFeatured, status, readTime, } = req.body;
const slug = slugify(title, {
lower: true,
strict: true,
trim: true,
});
const existingBlog = await Blog.findOne({ slug });
if (existingBlog) {
res.status(409).json({
success: false,
message: "A blog with this title already exists",
});
return;
}
const blog = await Blog.create({
title,
slug,
content,
excerpt,
category,
tags,
featuredImage,
seoTitle,
seoDescription,
isFeatured,
status,
readTime,
});
res.status(201).json({
success: true,
message: "Blog created successfully",
data: blog,
});
} catch (error) { next(error); } }; ```
Adding Request Validation
Never trust data coming from the client.
You can validate the request using Zod.
```ts import { z } from "zod";
export const createBlogSchema = z.object({ title: z.string().trim().min(5).max(150),
content: z.string().trim().min(100),
excerpt: z.string().trim().min(20).max(300),
category: z.string().regex(/^[0-9a-fA-F]{24}$/, { message: "Invalid category ID", }),
tags: z.array(z.string().trim()).default([]),
featuredImage: z.string().url(),
seoTitle: z.string().trim().min(5).max(70),
seoDescription: z.string().trim().min(50).max(170),
isFeatured: z.boolean().optional(),
status: z.enum(["draft", "published"]).optional(),
readTime: z.number().positive().optional(), }); ```
Validation protects your database from invalid or incomplete data.
Protecting Routes
Only authenticated users with the required permissions should be able to create or update blogs.
```ts import { Router } from "express"; import { createBlog } from "../controllers/blog.controller"; import { authenticate } from "../middleware/auth.middleware"; import { authorize } from "../middleware/authorize.middleware";
const router = Router();
router.post( "/", authenticate, authorize("blog:create"), createBlog );
export default router; ```
Authentication confirms the identity of a user, while authorization determines what that user can do.
Adding Redis Caching
Frequently requested blogs can be cached in Redis.
```ts import redisClient from "../config/redis"; import Blog from "../models/Blog";
export const getBlogBySlug = async (slug: string) => { const cacheKey = `blog:${slug}`;
const cachedBlog = await redisClient.get(cacheKey);
if (cachedBlog) { return JSON.parse(cachedBlog); }
const blog = await Blog.findOne({ slug, status: "published", isDeleted: false, }).populate("category");
if (!blog) { return null; }
await redisClient.setEx( cacheKey, 300, JSON.stringify(blog) );
return blog; }; ```
In this example, the blog remains cached for five minutes.
Whenever a blog is updated or deleted, remove the related cache entry.
```ts await redisClient.del(`blog:${blog.slug}`); ```
Implementing Soft Delete
Instead of permanently deleting a blog, mark it as deleted.
```ts const deletedBlog = await Blog.findByIdAndUpdate( blogId, { isDeleted: true, }, { new: true, } ); ```
When retrieving blogs, include `isDeleted: false` in the query.
```ts const blogs = await Blog.find({ isDeleted: false, status: "published", }); ```
Soft deletion allows administrators to restore content later.
Calculating Reading Time
You can calculate reading time based on the number of words.
```ts export const calculateReadTime = (content: string): number => { const wordsPerMinute = 200;
const wordCount = content .trim() .split(/\s+/) .filter(Boolean).length;
return Math.max(1, Math.ceil(wordCount / wordsPerMinute)); }; ```
Use this function before creating the blog:
```ts const readTime = calculateReadTime(content); ```
Adding Security Middleware
A production API should use security middleware such as Helmet and rate limiting.
```ts import helmet from "helmet"; import rateLimit from "express-rate-limit";
app.use(helmet());
const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 200, standardHeaders: true, legacyHeaders: false, });
app.use("/api", apiLimiter); ```
You should also:
- Validate all user input.
- Restrict CORS origins.
- Use secure HTTP-only cookies.
- Store secrets in environment variables.
- Apply role-based permissions.
- Use HTTPS in production.
- Log important security events.
Running the Application with PM2
Build the TypeScript application first.
```bash npm run build ```
Start the compiled server using PM2.
```bash pm2 start dist/server.js --name blog-backend ```
Save the PM2 process list:
```bash pm2 save ```
Check application logs:
```bash pm2 logs blog-backend ```
Restart the application after deployment:
```bash pm2 restart blog-backend --update-env ```
Deployment Script
A simple deployment script can automate the update process.
```bash #!/bin/bash
set -e
echo "Pulling latest code..." git pull origin main
echo "Installing dependencies..." npm ci
echo "Running type checking..." npm run type-check
echo "Building application..." npm run build
echo "Restarting PM2..." pm2 restart blog-backend --update-env
echo "Deployment completed successfully." ```
Make the script executable:
```bash chmod +x deploy.sh ```
Run it using:
```bash ./deploy.sh ```
Final Production Checklist
Before deploying your API, verify the following:
- Environment variables are configured.
- MongoDB is connected securely.
- Redis uses a hosted production URL.
- Authentication and authorization are enabled.
- Request validation is implemented.
- Errors are handled centrally.
- Rate limiting is enabled.
- CORS allows only trusted domains.
- The application runs through PM2.
- HTTPS is enabled.
- Database backups are configured.
- Application logs are monitored.
Conclusion
A production-ready REST API requires more than routes and controllers.
A strong backend should have:
- Clear project organization
- Secure authentication
- Permission-based authorization
- Input validation
- Centralized error handling
- Database indexing
- Redis caching
- Rate limiting
- Logging and monitoring
- A reliable deployment process
By applying these practices, your Express application will be easier to maintain, more secure, and better prepared to handle real users. `
Tags
Enjoyed this article?
Share it with your friends and colleagues.