Writing APIs With Relationships in MongoDB
Real APIs have relationships. Here is how to write APIs that handle them well.
Writing APIs With Relationships in MongoDB
Real APIs have relationships. Posts have authors. Messages have senders. Connections have two users. Here is how to write APIs that handle them well.
Pattern 1: Populate on Read
router.get('/posts/:id', asyncHandler(async (req, res) => {
const post = await Post.findById(req.params.id)
.populate('authorId', 'firstName lastName photoUrl');
if (!post) throw new ApiError(404, 'Post not found');
res.json(post);
}));
Returns the post with the author object embedded. The client gets everything in one response.
Pattern 2: Denormalize for High-Traffic Reads
// Message schema
{ senderId: ObjectId, senderName: String, text: String }
Store the sender's name on the message. Read messages without a join. When the user's name changes, update their messages (or accept staleness).
Pattern 3: Nested Routes for Owned Resources
router.get('/users/:userId/posts', asyncHandler(async (req, res) => {
const posts = await Post.find({ authorId: req.params.userId }).sort({ createdAt: -1 }).lean();
res.json(posts);
}));
Use when the resource is owned by another (posts by a user). Limit nesting to one level.
Pattern 4: Side-Loading for Lists
For lists with many relationships, side-load: fetch the parents and the referenced docs in parallel, then let the client connect them.
const posts = await Post.find().limit(20).lean();
const authorIds = [...new Set(posts.map(p => p.authorId.toString()))];
const authors = await User.find({ _id: { $in: authorIds } }).select('firstName lastName').lean();
res.json({ posts, authors });
Avoids N+1 and avoids large nested payloads.
Pattern 5: Cascade Deletes
When deleting a user, delete their posts and messages too:
router.delete('/users/:id', asyncHandler(async (req, res) => {
const userId = req.params.id;
await Post.deleteMany({ authorId: userId });
await Message.deleteMany({ senderId: userId });
await User.findByIdAndDelete(userId);
res.status(204).end();
}));
Do this in a transaction if your MongoDB supports it.
Pattern 6: Pagination With Populate
const posts = await Post.find()
.sort({ createdAt: -1 })
.skip(20)
.limit(20)
.populate('authorId', 'firstName lastName')
.lean();
populate works with skip and limit. Just be careful with large limits (populate does an extra query for all referenced docs).
Pattern 7: Filter by Referenced Field
const posts = await Post.find()
.populate({ path: 'authorId', match: { role: 'admin' } })
.lean();
Filters the populated author. Posts with non-admin authors get authorId: null. Filter them out in code if needed.
The Takeaway
Write APIs with relationships in MongoDB: populate on read for simple cases, denormalize for high-traffic reads, use nested routes for owned resources (limit to one level), side-load for lists with many relationships, cascade deletes (in a transaction), paginate with populate, and filter by referenced field with match in populate.
Use populate on read: Post.findById(id).populate('authorId', 'firstName lastName'). Returns the post with the author object embedded. The client gets everything in one response. Always select specific fields.
For lists with many relationships, fetch parents and referenced docs in parallel, return both, let the client connect them. Avoids N+1 and avoids large nested payloads. Example: { posts: [...], authors: [...] }.
When deleting a user, delete their posts and messages too. Use deleteMany for each related collection, then delete the user. Do it in a transaction if your MongoDB supports it, so a failure rolls back.
For high-traffic reads of rarely-changing data. Store the sender's name on the message so you do not need to populate. When the user's name changes, update their messages (or accept staleness for a while).
populate works with skip and limit. Post.find().sort({ createdAt: -1 }).skip(20).limit(20).populate('authorId', 'firstName'). Just be careful with large limits; populate does an extra query for all referenced docs.
Ready to master Node.js completely?
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course to dive deeper with high-quality video tutorials, solve interview questions, and a premium community.
Master Node.js
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course.

