Introduction
Building a content management system or blog on MongoDB requires careful thought about what to embed and what to separate into collections. The way you structure your data will directly impact query performance, scalability, and how easily you can update content across your system. In this guide on modeling a blog or CMS in MongoDB: a step-by-step guide, we'll walk through real production scenarios and show you how to balance denormalization with flexibility as your data grows.
Why document structure matters for content platforms
A typical blog or CMS handles millions of reads and constant writes. Users browse posts, leave comments, filter by tags, and search for content. Each of these access patterns pushes us toward different schema decisions. Unlike relational databases where everything is split into normalized tables, MongoDB lets us embed related data or keep it separate—and that choice shapes everything from query speed to storage costs.
We'll assume you're building a platform where:
- Posts are read frequently but updated rarely
- Comments grow without bound and need pagination
- Users often filter by tags or perform full-text searches
- Author information rarely changes
With those constraints, let's design the schema.
Posts: Embed tags and author summary
The post document is the heart of a CMS. It contains the article itself, metadata, and references to related entities. The key decision here is: do we embed the author object or just reference the author ID?
The rule: embed the author's summary (name, avatar, bio) but keep the full author document in a separate collection. Why? Because author info is read with every post, and we want to avoid a join. But author documents might be updated independently, and embedding the full author object would force denormalization overhead.
Here's a realistic post document:
{
_id: ObjectId("67a1234567890abcdef01234"),
title: "Scaling MongoDB for high-traffic applications",
slug: "scaling-mongodb-high-traffic",
content: "When your database starts to slow down...",
excerpt: "Tips and patterns for handling millions of writes",
authorId: ObjectId("67a1111111111111111111aa"),
author: {
_id: ObjectId("67a1111111111111111111aa"),
name: "Alice Chen",
avatar: "https://cdn.example.com/avatars/alice.jpg",
bio: "Platform engineer, 10+ years experience"
},
tags: ["mongodb", "performance", "scaling"],
status: "published",
createdAt: ISODate("2025-01-15T10:30:00Z"),
updatedAt: ISODate("2025-01-15T10:30:00Z"),
publishedAt: ISODate("2025-01-15T11:00:00Z"),
commentCount: 42,
viewCount: 2841
}
Notice several design choices here:
- Tags are embedded as an array of strings. They're small, searchable, and rarely change after publication. If tag metadata (description, icon) is needed, create a separate tags collection and query it separately for the tag detail page.
- Author is both embedded and referenced. The embedded object has only what readers need; the authorId allows us to find and update the full author record independently.
- Timestamps are explicit. publishedAt differs from createdAt (drafts might be created but not published immediately).
- commentCount is denormalized here. We'll see why in the next section.
Comments: Separate collection for unbounded growth
Comments are a classic example of data that should not be embedded in the post document, even though it might seem convenient. Comments can grow indefinitely—a popular post might accumulate thousands of comments—and embedding them in the post would cause the document to grow without limit. This creates memory pressure and slows down every post query.
Keep comments in a separate collection with a reference back to the post:
// comments collection
{
_id: ObjectId("67a2234567890abcdef02234"),
postId: ObjectId("67a1234567890abcdef01234"),
authorId: ObjectId("67a1111111111111111111bb"),
author: {
_id: ObjectId("67a1111111111111111111bb"),
name: "Bob Martinez",
avatar: "https://cdn.example.com/avatars/bob.jpg"
},
text: "This is exactly what we needed for our infrastructure team.",
status: "approved",
createdAt: ISODate("2025-01-15T12:45:00Z"),
updatedAt: ISODate("2025-01-15T12:45:00Z"),
likes: 15,
replies: [
{
_id: ObjectId("67a2234567890abcdef02235"),
authorId: ObjectId("67a1111111111111111111cc"),
author: {
_id: ObjectId("67a1111111111111111111cc"),
name: "Carol Singh",
avatar: "https://cdn.example.com/avatars/carol.jpg"
},
text: "Glad it helped! Have you tried it yet?",
createdAt: ISODate("2025-01-15T13:10:00Z"),
likes: 3
}
]
}
A few observations:
- We embed the author summary here too, for the same reason as posts—readers need it immediately.
- Replies are embedded within a comment, but only to a shallow depth. If replies themselves grew unbounded, we'd need to reconsider and nest them separately.
- The postId foreign key lets us query all comments for a post with a simple find.
- Status field allows moderation (approved, pending, spam).
To fetch a post with its most recent comments, you'd run two queries:
// Get the post
const post = await db.collection("posts").findOne({
_id: ObjectId("67a1234567890abcdef01234")
});
// Get the first 20 comments, sorted by newest
const comments = await db.collection("comments").find({
postId: post._id,
status: "approved"
}).sort({ createdAt: -1 }).limit(20).toArray();
Counters: Denormalizing counts for performance
Notice that in the post document, we included a commentCount field. This is a denormalization pattern that trades consistency for query speed. Every time a comment is created, we increment the post's commentCount. This avoids running a count aggregation across the comments collection every time we display the post.
Use the atomic updateOne operation to keep this safe:
// When a new comment is approved
const postId = ObjectId("67a1234567890abcdef01234");
// Increment the counter atomically
await db.collection("posts").updateOne(
{ _id: postId },
{ $inc: { commentCount: 1 } }
);
// When a comment is deleted
await db.collection("posts").updateOne(
{ _id: postId },
{ $inc: { commentCount: -1 } }
);
The $inc operator is atomic, so even under high load, the count stays accurate. The tradeoff is that if a comment is deleted but the counter update fails, you'll have a stale count—but that's typically acceptable for a visual metric. If you need perfect accuracy, you can periodically reconcile the count via a background job.
Similarly, denormalize viewCount on posts to track read popularity without querying analytics logs.
Search: Atlas Search for full-text indexing
Full-text search on blog content is too complex for MongoDB's basic text index in production. MongoDB Atlas Search (powered by Apache Lucene) is designed exactly for this problem. It handles typos, stemming, synonyms, and faceted filtering.
First, create a search index in the Atlas console or via the Aggregation Pipeline:
// Create a search index on posts
// (done in Atlas UI or programmatically)
// Basic config:
{
"mappings": {
"dynamic": false,
"fields": {
"title": {
"type": "string",
"analyzer": "lucene.standard"
},
"content": {
"type": "string",
"analyzer": "lucene.standard"
},
"tags": {
"type": "string"
}
}
}
}
Then query it with $search:
const results = await db.collection("posts").aggregate([
{
$search: {
index: "default",
text: {
query: "scaling mongodb",
path: ["title", "content"],
fuzzy: {
maxEdits: 1
}
}
}
},
{
$match: {
status: "published"
}
},
{
$sort: {
publishedAt: -1
}
},
{
$limit: 20
}
]).toArray();
The $search stage runs before $match, so the search engine scores results, then we filter to published posts only. This keeps search indexes lean and results relevant. For tag-based filtering, you can add a separate facet:
// Faceted search: show tag counts alongside results
const results = await db.collection("posts").aggregate([
{
$searchMeta: {
facet: {
operator: {
text: {
query: "mongodb",
path: "title"
}
},
facets: {
tagFacet: {
type: "string",
path: "tags",
numBuckets: 10
}
}
}
}
}
]).toArray();
Common mistakes
Embedding comments in posts: As mentioned, this leads to unbounded document growth. Always separate comments into their own collection.
Over-embedding author objects: Embedding the full author document (including email, password hash, preferences) in posts and comments creates inconsistency when an author updates their profile. Embed only the display fields.
Forgetting to index foreign keys: Always index postId in the comments collection and authorId in posts. Without indexes, queries become table scans.
Ignoring comment moderation: Don't assume all comments are approved. Include a status field and filter on it in queries, or you'll expose spam or offensive content.
Using basic text index for production search: MongoDB's built-in text index is weak compared to Atlas Search. For anything beyond simple keyword matching, use Atlas Search.
Key takeaways
- Embed author summaries in posts and comments, but keep the full author document separate to avoid denormalization hell.
- Always separate comments into their own collection—they grow without bound and shouldn't be stored in post documents.
- Denormalize critical counts (commentCount, viewCount) on the post and update them atomically with $inc to avoid expensive aggregations.
- Use array fields like tags for small, enumerated values that readers need alongside the post; create a separate collection if tag metadata is rich.
- Leverage MongoDB Atlas Search for full-text indexing, fuzzy matching, and faceted filtering instead of relying on basic text indexes.
- Always index foreign keys (postId, authorId) to keep queries performant as data scales.
- Design with two-query reads in mind: fetch the post, then fetch paginated comments separately.






Comments
Loading comments…