How I Would Build a Blogging Platform (With Real Schema & Scalable Logic)
A few weeks ago I was helping a friend build a blog platform. He asked me, “Bro, how do people like Medium or Hashnode manage millions of blogs?”
And I was like: “It’s all about the schema, baby.” 😎
So here’s a breakdown of how I would personally design a blog system—with scalable schema, reference-based relationships, and optimizations that actually matter.
🔧 Core Features We Need
- User Signup / Login
- Write & Edit Blog Posts
- Add Tags / Categories
- Comment System
- Like/Clap System
- SEO-Friendly URLs (slug)
- Author Pages
🧑💻 User Schema (Basic but essential)
User {
_id: ObjectId,
name: String,
username: String, // unique
email: String, // unique
passwordHash: String,
profilePicUrl: String,
bio: String,
createdAt: Date,
updatedAt: Date
}
📝 Post Schema (Reference-based)
Post {
_id: ObjectId,
title: String,
content: String, // HTML or Markdown
author: ObjectId, // ref: "User"
slug: String, // unique per post
tags: [ObjectId], // ref: "Tag"
coverImageUrl: String,
isPublished: Boolean,
likeCount: Number,
commentCount: Number,
createdAt: Date,
updatedAt: Date,
// for seo point of view must include.
metaTitle: String,
metaDescription: String,
metaKeywords: Array
}
🏷️ Tag Schema
Tag {
_id: ObjectId,
name: String, // e.g., "javascript"
description: String,
createdAt: Date
}
💬 Comment Schema (Threaded replies)
Comment {
_id: ObjectId,
postId: ObjectId, // ref: "Post"
author: ObjectId, // ref: "User"
content: String,
parentCommentId: ObjectId, // for replies
createdAt: Date
}
❤️ Like/Clap Schema
Like {
_id: ObjectId,
postId: ObjectId, // ref: "Post"
userId: ObjectId, // ref: "User"
createdAt: Date
}
🗺️ Folder Structure Suggestion
/models
- user.model.js
- post.model.js
- comment.model.js
- like.model.js
- tag.model.js
Mongoose Relationship Example
author: { type: Schema.Types.ObjectId, ref: 'User' }
Refer the Queries you will be using
💻 Real MongoDB Queries You’ll Actually Use
const post = await Post.findOne({ slug: 'how-to-build-blog-platform' })
.populate('author', 'username profilePicUrl')
.populate('tags', 'name description');
Explanation: This query is used to find a blog post based on the slug. You’ll typically call this query when a user lands on a specific blog page — just like you're viewing this page right now. Behind the scenes, I fetch the blog from the database using the slug and return it to the frontend. Here, I’ve also used populate()
to fetch related information like author and tags from referenced collections.
Why We Use Reference-Based Schema
- Because we need to update data only once, and it reflects everywhere automatically.
- Cleaner structure and faster to query using relationships like
populate()
. - Keeps the database normalized and avoids redundant data storage.
- Makes it easy to scale and manage large datasets across multiple collections.
const posts = await Post.find({ author: userId, isPublished: true })
.sort({ createdAt: -1 })
.limit(10);
Explanation: This query is used to find the maximum post of 10 of particular author and the published one not in draft.
const posts = await Post.find({ tags: tagId, isPublished: true })
.sort({ createdAt: -1 });
Explanation: Finding post based on tags.
const comments = await Comment.find({ postId: postId, parentCommentId: null })
.populate('author', 'username profilePicUrl')
.sort({ createdAt: 1 });
Explanation: To get all the comments of particular post.
const replies = await Comment.find({ parentCommentId: commentId });
Explanation: To get all the sub-comments of particular comment.
const newPost = await Post.create({
title: "My First Blog",
content: "# Hello World",
author: userId,
slug: "my-first-blog",
tags: [tagId1, tagId2],
isPublished: true,
createdAt: new Date(),
});
Explanation: Creating a new post
const alreadyLiked = await Like.findOne({ postId, userId });
if (!alreadyLiked) {
await Like.create({ postId, userId, createdAt: new Date() });
await Post.findByIdAndUpdate(postId, { $inc: { likeCount: 1 } });
}
await Like.deleteOne({ postId, userId });
await Post.findByIdAndUpdate(postId, { $inc: { likeCount: -1 } });
const likeCount = await Like.countDocuments({ postId });
const comment = await Comment.create({
postId,
author: userId,
content: "Awesome blog post!",
createdAt: new Date(),
});
await Post.findByIdAndUpdate(postId, { $inc: { commentCount: 1 } });
const tag = await Tag.create({
name: "javascript",
description: "All about JavaScript!",
});
const tags = await Tag.find().sort({ name: 1 });
const exists = await Post.findOne({ slug: "unique-slug" });
if (exists) {
throw new Error("Slug already exists");
}
const posts = await Post.find({ isPublished: true })
.sort({ createdAt: -1 })
.limit(10)
.skip(page * 10);
⚙️ Performance Optimizations
- Indexes: slug, author (Post), postId (Comment & Like), tags (Post)
- Pagination: Use createdAt + _id for cursor-based pagination
- Caching: Use Redis for popular posts/tags, CDN for static content
🔒 Security Pointers
- Sanitize Markdown/HTML content before rendering
- Use express-rate-limit for spam protection
- Hash passwords with bcrypt
- Escape output on frontend to prevent XSS