Loading

Quipoin Menu

Learn • Practice • Grow

express-js / Mini Full-Stack Project (Blog/E-commerce API)
tutorial

Mini Full-Stack Project (Blog/E-commerce API)

Congratulations! You've learned all the core concepts of Express. Now it's time to build something real – a mini full-stack project that brings everything together. We'll build a blog platform API that can be extended to an e-commerce site with minor modifications.

Project Overview: Blog API

We'll build a blog platform with these features:
  • User registration and login (JWT authentication)
  • User profiles with bio and avatar upload
  • Blog posts with CRUD operations
  • Comments on posts
  • Categories and tags
  • Likes/bookmarks
  • Search and filtering
  • Pagination
  • Rate limiting
  • File uploads for avatars and post images

Project Structure
blog-api/
├── src/
│ ├── config/
│ │ ├── config.js
│ │ ├── db.js
│ │ └── cloudinary.js (for image uploads)
│ ├── models/
│ │ ├── User.js
│ │ ├── Post.js
│ │ ├── Comment.js
│ │ ├── Category.js
│ │ └── Like.js
│ ├── controllers/
│ │ ├── authController.js
│ │ ├── userController.js
│ │ ├── postController.js
│ │ ├── commentController.js
│ │ └── categoryController.js
│ ├── routes/
│ │ ├── authRoutes.js
│ │ ├── userRoutes.js
│ │ ├── postRoutes.js
│ │ ├── commentRoutes.js
│ │ └── categoryRoutes.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── upload.js (multer config)
│ │ ├── validation.js
│ │ └── rateLimiter.js
│ ├── utils/
│ │ ├── AppError.js
│ │ ├── catchAsync.js
│ │ └── apiFeatures.js
│ └── app.js
├── .env├── package.json
└── README.md

Key Models

User Model (enhanced)
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
bio: { type: String, maxlength: 500 },
avatar: { type: String, default: 'default-avatar.jpg' },
website: String,
location: String,
role: { type: String, enum: ['user', 'admin'], default: 'user' },
isActive: { type: Boolean, default: true },
lastLogin: Date,
passwordChangedAt: Date,
passwordResetToken: String,
passwordResetExpires: Date
}, { timestamps: true });

Post Model (enhanced)
const postSchema = new mongoose.Schema({
title: { type: String, required: true, index: true },
slug: { type: String, unique: true, required: true },
content: { type: String, required: true },
excerpt: { type: String, maxlength: 300 },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category' },
tags: [String],
featuredImage: String,
published: { type: Boolean, default: false },
publishedAt: Date,
views: { type: Number, default: 0 },
likesCount: { type: Number, default: 0 },
commentsCount: { type: Number, default: 0 }
}, { timestamps: true });

// Text search index
postSchema.index({ title: 'text', content: 'text' });

Comment Model
const commentSchema = new mongoose.Schema({
content: { type: String, required: true },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
post: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', required: true },
parentComment: { type: mongoose.Schema.Types.ObjectId, ref: 'Comment' }, // For nested comments
likes: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
isEdited: { type: Boolean, default: false }
}, { timestamps: true });

Category Model
const categorySchema = new mongoose.Schema({
name: { type: String, required: true, unique: true },
slug: { type: String, required: true, unique: true },
description: String,
parentCategory: { type: mongoose.Schema.Types.ObjectId, ref: 'Category' } // For subcategories
});

API Features Utility (utils/apiFeatures.js)
class APIFeatures {
constructor(query, queryString) {
this.query = query;
this.queryString = queryString;
}

filter() {
const queryObj = { ...this.queryString };
const excludedFields = ['page', 'sort', 'limit', 'fields', 'search'];
excludedFields.forEach(el => delete queryObj[el]);

// Advanced filtering (gt, gte, lt, lte)
let queryStr = JSON.stringify(queryObj);
queryStr = queryStr.replace(/b(gte|gt|lte|lt)b/g, match => `$${match}`);

this.query = this.query.find(JSON.parse(queryStr));
return this;
}

search() {
if (this.queryString.search) {
this.query = this.query.find({
$text: { $search: this.queryString.search }
});
}
return this;
}

sort() {
if (this.queryString.sort) {
const sortBy = this.queryString.sort.split(',').join(' ');
this.query = this.query.sort(sortBy);
} else {
this.query = this.query.sort('-createdAt');
}
return this;
}

limitFields() {
if (this.queryString.fields) {
const fields = this.queryString.fields.split(',').join(' ');
this.query = this.query.select(fields);
} else {
this.query = this.query.select('-__v');
}
return this;
}

paginate() {
const page = parseInt(this.queryString.page) || 1;
const limit = parseInt(this.queryString.limit) || 10;
const skip = (page - 1) * limit;

this.query = this.query.skip(skip).limit(limit);
return this;
}
}

module.exports = APIFeatures;

Post Controller with API Features
const Post = require('../models/Post');
const APIFeatures = require('../utils/apiFeatures');
const catchAsync = require('../utils/catchAsync');
const AppError = require('../utils/AppError');

exports.getAllPosts = catchAsync(async (req, res) => {
// Build query
const features = new APIFeatures(Post.find(), req.query)
.search()
.filter()
.sort()
.limitFields()
.paginate();

// Execute query
const posts = await features.query.populate('author', 'name avatar');
const total = await Post.countDocuments();

res.json({
success: true,
results: posts.length,
total,
page: parseInt(req.query.page) || 1,
pages: Math.ceil(total / (parseInt(req.query.limit) || 10)),
data: posts
});
});

exports.getPost = catchAsync(async (req, res, next) => {
const post = await Post.findById(req.params.id)
.populate('author', 'name avatar')
.populate({
path: 'comments',
populate: { path: 'author', select: 'name avatar' }
});

if (!post) {
return next(new AppError('Post not found', 404));
}

// Increment view count
post.views += 1;
await post.save({ validateBeforeSave: false });

res.json({ success: true, data: post });
});

File Upload with Cloudinary (config/cloudinary.js)
const cloudinary = require('cloudinary').v2;
const { CloudinaryStorage } = require('multer-storage-cloudinary');
const multer = require('multer');

// Configure Cloudinary
cloudinary.config({
cloud_name: process.env.CLOUDINARY_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET
});

// Configure storage
const storage = new CloudinaryStorage({
cloudinary: cloudinary,
params: {
folder: 'blog-api',
allowed_formats: ['jpg', 'jpeg', 'png', 'gif'],
transformation: [{ width: 1000, height: 1000, crop: 'limit' }]
}
});

const upload = multer({ storage });

module.exports = { cloudinary, upload };

Rate Limiting for API
const rateLimit = require('express-rate-limit');

const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: 'Too many attempts, please try again after 15 minutes'
});

const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Rate limit exceeded, please slow down'
});

module.exports = { authLimiter, apiLimiter };

Extending to E-commerce
To convert this blog API to an e-commerce API, you would:
  • Create Product model instead of Post model (with price, stock, SKU, etc.)
  • Add Order and Cart models
  • Add payment processing (Stripe, PayPal)
  • Add shipping information
  • Add inventory management
  • Add reviews and ratings instead of comments

Two Minute Drill
  • This mini full-stack project combines everything you've learned.
  • Use proper project structure for scalability.
  • Implement advanced features like search, filtering, and pagination.
  • Use Cloudinary for image uploads (better than local storage).
  • Add rate limiting to protect your API.
  • This structure can be easily adapted for e-commerce.
  • Now you're ready to build real-world applications!

Need more clarification?

Drop us an email at career@quipoinfotech.com