Nest.js Blog Step by Step: Reply Comment
Wenhao Wang
Dev Intern · Leapcell

In the previous article, we added a comment feature to our blog, allowing for initial interaction between readers and the author. However, all comments were displayed linearly, making it difficult to follow conversations when discussions became lively.
To build a more interactive community, in this tutorial, we will upgrade the comment system to implement a comment reply feature, also known as "nested comments" or "threaded comments."
We will achieve the following goals:
- Allow users to reply to existing comments.
- Clearly display the reply hierarchy on the page in a nested (or indented) format.
- Enhance the user experience with some simple client-side JavaScript.
Step 1: Update the Data Model
To implement the reply feature, we need to establish a parent-child relationship between comments. A reply is essentially a comment, but it has a "parent comment." We will achieve this by adding a self-referencing relationship to the Comment
entity.
1. Modify the Database Table
First, we need to modify the structure of the comment
table by adding a parentId
field to point to the ID of its parent comment.
Execute the following ALTER TABLE
statement in your PostgreSQL database:
ALTER TABLE "comment" ADD COLUMN "parentId" UUID REFERENCES "comment"("id") ON DELETE CASCADE;
- The
parentId
column is optional (allowsNULL
) because top-level comments do not have a parent. REFERENCES "comment"("id")
creates a foreign key that linksparentId
to theid
column of the same table.
2. Update the Comment Entity
Now, open the src/comments/comment.entity.ts
file and add the parent
and replies
properties to reflect this hierarchical relationship in the code.
// src/comments/comment.entity.ts import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, ManyToOne, OneToMany } from 'typeorm'; import { User } from '../users/user.entity'; import { Post } from '../posts/post.entity'; @Entity() export class Comment { @PrimaryGeneratedColumn('uuid') id: string; @Column('text') content: string; @CreateDateColumn() createdAt: Date; @ManyToOne(() => User, user => user.comments) user: User; @ManyToOne(() => Post, post => post.comments) post: Post; // --- New Fields --- @ManyToOne(() => Comment, comment => comment.replies, { nullable: true }) parent: Comment; // Parent comment @OneToMany(() => Comment, comment => comment.parent) replies: Comment[]; // List of child comments (replies) }
Step 2: Adjust the Comment Service
Our service layer needs to be adjusted to associate a parent comment when creating a new comment and to structure the flat list of comments into a tree-like structure when querying.
Open src/comments/comments.service.ts
and make the following changes:
// src/comments/comments.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Comment } from './comment.entity'; import { Post } from '../posts/post.entity'; import { User } from '../users/user.entity'; @Injectable() export class CommentsService { constructor( @InjectRepository(Comment) private commentsRepository: Repository<Comment>, ) {} // Modify the findByPostId method async findByPostId(postId: string): Promise<Comment[]> { const comments = await this.commentsRepository.find({ where: { post: { id: postId } }, relations: ['user', 'parent'], // Load user and parent simultaneously order: { createdAt: 'ASC', }, }); return this.structureComments(comments); } // Add a new private method to convert a flat list into a tree structure private structureComments(comments: Comment[]): Comment[] { const commentMap = new Map<string, Comment>(); comments.forEach(comment => { comment.replies = []; // Initialize the replies array commentMap.set(comment.id, comment); }); const rootComments: Comment[] = []; comments.forEach(comment => { if (comment.parent) { const parentComment = commentMap.get(comment.parent.id); if (parentComment) { parentComment.replies.push(comment); } } else { rootComments.push(comment); } }); return rootComments; } // Modify the create method to accept an optional parentId async create( content: string, user: User, post: Post, parentId?: string, ): Promise<Comment> { const newComment = this.commentsRepository.create({ content, user, post, parent: parentId ? { id: parentId } as Comment : null, }); return this.commentsRepository.save(newComment); } }
Explanation of Logic:
findByPostId
now fetches all comments for a post (including top-level comments and all replies).- The new
structureComments
method is the core of this logic. It first places all comments into aMap
for quick lookups. Then, it iterates through all comments. If a comment has aparent
, it is pushed into the parent'sreplies
array; otherwise, it is a top-level comment. - The
create
method now has an optionalparentId
parameter. If this ID is provided, the newly created comment will be associated with the corresponding parent comment.
Step 3: Update the Controller
The controller needs to receive the optional parentId
from the request body and pass it to the service. This change is very straightforward.
Open src/comments/comments.controller.ts
:
// src/comments/comments.controller.ts import { Controller, Post, Body, Param, Req, Res, UseGuards } from '@nestjs/common'; // ... imports @Controller('posts/:postId/comments') export class CommentsController { constructor(private readonly commentsService: CommentsService) {} @UseGuards(AuthenticatedGuard) @Post() async create( @Param('postId') postId: string, @Body('content') content: string, @Body('parentId') parentId: string, // <-- Receive parentId @Req() req: Request, @Res() res: Response, ) { const user = req.user as any; // Pass parentId to the service await this.commentsService.create(content, user, { id: postId } as any, parentId); res.redirect(`/posts/${postId}`); } }
Step 4: Upgrade the Frontend View
This is the most interesting part. We need to update the post.ejs
template to recursively render comments and their replies. We also need to add some JavaScript to dynamically display the reply form.
1. Create a Reusable Comment Template
For recursive rendering, the best practice is to create a reusable "partial" template. In the views
directory, create a new file named _comment.ejs
:
<% comments.forEach(comment => { %> <div class="comment-item" style="margin-left: <%= depth * 20 %>px;"> <p class="comment-content"><%= comment.content %></p> <small> By <strong><%= comment.user.username %></strong> on <%= new Date(comment.createdAt).toLocaleDateString() %> </small> <% if (user) { %> <button class="reply-btn" data-comment-id="<%= comment.id %>">Reply</button> <% } %> </div> <% if (comment.replies && comment.replies.length > 0) { %> <%- include('_comment', { comments: comment.replies, user: user, post: post, depth: depth + 1 }) %> <% } %> <% }) %>
This template iterates through the incoming comments
array and recursively calls itself for each comment's replies
array, while incrementing the depth
to achieve stylistic indentation.
2. Update post.ejs
Now, modify views/post.ejs
to use this new _comment.ejs
partial and add a universal reply form.
<section class="comments-section"> <h3>Comments</h3> <div class="comment-list"> <% if (comments.length > 0) { %> <%- include('_comment', { comments: comments, user: user, post: post, depth: 0 }) %> <% } else { %> <p>No comments yet. Be the first to comment!</p> <% } %> </div> <% if (user) { %> <form id="comment-form" action="/posts/<%= post.id %>/comments" method="POST" class="comment-form"> <h4>Leave a Comment</h4> <div class="form-group"> <textarea name="content" rows="4" placeholder="Write your comment here..." required></textarea> <input type="hidden" name="parentId" id="parentIdInput" value="" /> </div> <button type="submit">Submit</button> <button type="button" id="cancel-reply-btn" style="display: none;">Cancel Reply</button> </form> <% } else { %> <p><a href="/auth/login">Login</a> to leave a comment.</p> <% } %> </section> <script> document.addEventListener('DOMContentLoaded', () => { const commentForm = document.getElementById('comment-form'); const parentIdInput = document.getElementById('parentIdInput'); const formTitle = commentForm.querySelector('h4'); const cancelReplyBtn = document.getElementById('cancel-reply-btn'); const commentList = document.querySelector('.comment-list'); commentList.addEventListener('click', (e) => { if (e.target.classList.contains('reply-btn')) { const commentId = e.target.getAttribute('data-comment-id'); const commentItem = e.target.closest('.comment-item'); // Move the form below the comment being replied to commentItem.after(commentForm); // Set the parentId and form title parentIdInput.value = commentId; formTitle.innerText = 'Replying to ' + commentItem.querySelector('strong').innerText; cancelReplyBtn.style.display = 'inline-block'; } }); cancelReplyBtn.addEventListener('click', () => { // Reset the form state parentIdInput.value = ''; formTitle.innerText = 'Leave a Comment'; cancelReplyBtn.style.display = 'none'; // Move the form back to the bottom of the comments section document.querySelector('.comments-section').appendChild(commentForm); }); }); </script> <%- include('_footer') %>
Frontend Logic Explained:
- There is only one comment form on the page.
- When a user clicks the "Reply" button on a comment, the JavaScript will:
- Get the ID of that comment.
- Set this ID in the hidden
parentId
input field of the form. - For a better user experience, move the entire form to be just below the comment being replied to.
- Display a "Cancel Reply" button.
- After clicking "Cancel Reply" or submitting the form, the form can be reset and moved back to its original position.
Run and Test
Restart your application (npm run start:dev
) and refresh the post page:
- As a logged-in user, post a top-level comment.
- Click the "Reply" button next to the comment you just posted. The form will move below that comment.
- Enter your reply in the form and submit it.
- After the page refreshes, you will see your reply displayed indented below the parent comment.
- You can continue replying to replies, creating deeper levels of conversation.
Conclusion
In this tutorial, we have successfully added a threaded reply feature to our blog. By establishing a self-referencing relationship in the data model, adjusting the backend service to handle a tree structure, and combining it with some simple client-side JavaScript, we have greatly enhanced the interactivity of our blog.