Build Your Own Forum with FastAPI: Step 6 - Comments and Replies
Grace Collins
Solutions Engineer · Leapcell

In the previous article, we added the post editing feature to our forum, allowing users to modify their published content.
Besides posting, interaction is essential for a forum. When users see an interesting (or controversial) post, they'll want to express their opinions.
In this article, we will add an interaction feature to our forum: implementing post comments and replies, allowing users to have discussions around posts.
Step 1: Update the Database Model
We need a new table to store comments. Furthermore, comments themselves need to support replies to form a hierarchical structure.
In models.py, add the Comment model and update the User and Post models to establish relationships.
models.py (Updated)
from sqlalchemy import Column, Integer, String, ForeignKey from sqlalchemy.orm import relationship from database import Base class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=True, index=True) hashed_password = Column(String) posts = relationship("Post", back_populates="owner", cascade="all, delete-orphan") comments = relationship("Comment", back_populates="owner", cascade="all, delete-orphan") class Post(Base): __tablename__ = "posts" id = Column(Integer, primary_key=True, index=True) title = Column(String, index=True) content = Column(String) owner_id = Column(Integer, ForeignKey("users.id")) owner = relationship("User", back_populates="posts") comments = relationship("Comment", back_populates="post", cascade="all, delete-orphan") class Comment(Base): __tablename__ = "comments" id = Column(Integer, primary_key=True, index=True) content = Column(String, nullable=False) post_id = Column(Integer, ForeignKey("posts.id")) owner_id = Column(Integer, ForeignKey("users.id")) parent_id = Column(Integer, ForeignKey("comments.id"), nullable=True) owner = relationship("User", back_populates="comments") post = relationship("Post", back_populates="comments") # Self-referencing relationship for replies parent = relationship("Comment", back_populates="replies", remote_side=[id]) replies = relationship("Comment", back_populates="parent", cascade="all, delete-orphan")
Here are the main changes we made:
- Created the
Commentmodel, linking it to posts and users viapost_idandowner_id, respectively. Theparent_idfield points to theidof another comment. If it'sNULL, it's a top-level comment; otherwise, it's a reply. - Updated the
UserandPostmodels, adding a relationship toComment.cascade="all, delete-orphan"ensures that when a user or post is deleted, their associated comments are also deleted.
Next, create this new table in your database. The corresponding SQL statement is as follows:
CREATE TABLE comments ( id SERIAL PRIMARY KEY, content TEXT NOT NULL, post_id INTEGER NOT NULL, owner_id INTEGER NOT NULL, parent_id INTEGER, CONSTRAINT fk_post FOREIGN KEY(post_id) REFERENCES posts(id) ON DELETE CASCADE, CONSTRAINT fk_owner FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_parent FOREIGN KEY(parent_id) REFERENCES comments(id) ON DELETE CASCADE );
If your database was created using Leapcell,
you can execute these SQL statements directly in its web-based operation panel.

Step 2: Create the Post Detail Page and Comment Section
Currently, all posts are displayed on the homepage. To make space for a comment section, we need to create a separate detail page for each post.
First, create a new file post_detail.html in the templates folder.
templates/post_detail.html
<!DOCTYPE html> <html> <head> <title>{{ post.title }} - My FastAPI Forum</title> <style> body { font-family: sans-serif; margin: 2em; } .post-container { border: 1px solid #ccc; padding: 20px; margin-bottom: 20px; } .comment-form { margin-top: 20px; } .comments-section { margin-top: 30px; } .comment { border-left: 3px solid #eee; padding-left: 15px; margin-bottom: 15px; } .comment .meta { font-size: 0.9em; color: #666; } .replies { margin-left: 30px; } </style> </head> <body> <div class="post-container"> <h1>{{ post.title }}</h1> <p>{{ post.content }}</p> <small>Author: {{ post.owner.username }}</small> </div> <hr /> <div class="comment-form"> <h3>Post a Comment</h3> {% if current_user %} <form action="/posts/{{ post.id }}/comments" method="post"> <textarea name="content" rows="4" style="width:100%;" placeholder="Write your comment..." required></textarea><br /> <button type="submit">Submit</button> </form> {% else %} <p><a href="/login">Log in</a> to post a comment.</p> {% endif %} </div> <div class="comments-section"> <h2>Comments</h2> {% for comment in comments %} {% if not comment.parent_id %} <div class="comment"> <p>{{ comment.content }}</p> <p class="meta">Posted by {{ comment.owner.username }}</p> {% if current_user %} <form action="/posts/{{ post.id }}/comments" method="post" style="margin-left: 20px;"> <input type="hidden" name="parent_id" value="{{ comment.id }}" /> <textarea name="content" rows="2" style="width:80%;" placeholder="Reply..." required></textarea><br /> <button type="submit">Reply</button> </form> {% endif %} <div class="replies"> {% for reply in comment.replies %} <div class="comment"> <p>{{ reply.content }}</p> <p class="meta">Replied by {{ reply.owner.username }}</p> </div> {% endfor %} </div> </div> {% endif %} {% endfor %} </div> <a href="/posts">Back to Home</a> </body> </html>
This template includes the post's details, a form for posting new comments, and an area to display all comments. For simplicity, we are currently only displaying one level of replies.
Step 3: Implement Backend Route Logic
Next, we'll add new routes in main.py to handle displaying the post detail page and submitting comments.
main.py (Add new routes)
# ... (previous imports remain unchanged) ... from sqlalchemy.orm import selectinload # ... (previous code remain unchanged) ... # --- Routes --- # ... (previous routes /, /posts, /api/posts, etc. remain unchanged) ... @app.get("/posts/{post_id}", response_class=HTMLResponse) async def view_post_detail( request: Request, post_id: int, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # Query for the post result = await db.execute(select(models.Post).where(models.Post.id == post_id).options(selectinload(models.Post.owner))) post = result.scalar_one_or_none() if not post: raise HTTPException(status_code=404, detail="Post not found") # Query for comments, and preload author and reply info # Use selectinload to avoid N+1 queries comment_result = await db.execute( select(models.Comment) .where(models.Comment.post_id == post_id) .options(selectinload(models.Comment.owner), selectinload(models.Comment.replies).selectinload(models.Comment.owner)) .order_by(models.Comment.id) ) comments = comment_result.scalars().all() return templates.TemplateResponse("post_detail.html", { "request": request, "post": post, "comments": comments, "current_user": current_user }) @app.post("/posts/{post_id}/comments") async def create_comment( post_id: int, content: str = Form(...), parent_id: Optional[int] = Form(None), db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): if not current_user: return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) new_comment = models.Comment( content=content, post_id=post_id, owner_id=current_user.id, parent_id=parent_id ) db.add(new_comment) await db.commit() return RedirectResponse(url=f"/posts/{post_id}", status_code=status.HTTP_303_SEE_OTHER) # ... (subsequent routes /posts/{post_id}/edit, /register, /login, /logout, etc. remain unchanged) ...
We added two new routes:
GET /posts/{post_id}: Finds the post from the database based onpost_idand queries for all comments related to that post. Finally, it renders thepost_detail.htmltemplate, passing the post, comments, and current user information to it.POST /posts/{post_id}/comments: This route handles the submission of comments and replies, creating aCommentobject and saving it to the database. It receivescontentand an optionalparent_idfrom the form. Ifparent_idexists, it means this is a reply.
Step 4: Add an Entry Point on the Homepage
Everything is ready, we just need an entry point from the homepage to the post detail page. Modify templates/posts.html to turn the post title into a link.
We just need to wrap <h3>{{ post.title }}</h3> with an <a> tag, linking it to /posts/{{ post.id }}.
templates/posts.html (Updated)
... (file header and stylesheet remain unchanged) ... <body> ... (header and post form sections remain unchanged) ... <hr /> <h2>Post List</h2> {% for post in posts %} <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 10px;"> <a href="/posts/{{ post.id }}"><h3>{{ post.title }}</h3></a> <p>{{ post.content }}</p> <small>Author: {{ post.owner.username if post.owner else 'Unknown' }}</small> {% if current_user and post.owner_id == current_user.id %} <div style="margin-top: 10px;"> <a href="/posts/{{ post.id }}/edit">Edit</a> </div> {% endif %} </div> {% endfor %} </body> </html>
Run and Verify
Restart your uvicorn server:
uvicorn main:app --reload
Visit http://127.0.0.1:8000 and log in. On the homepage, you will find that all post titles have now become clickable links.

Click on any post title, and the page will redirect to that post's detail page.
Enter content in the comment box at the bottom of the detail page and click 'Submit'. After the page refreshes, your comment will appear in the comment section.

Below the comment, there is a smaller reply box. Enter content in it and submit, and you will see a comment as a reply.

Summary
The forum now supports comment and reply features, allowing users to interact with each other.
As the forum's functionality becomes more complex, maintaining community order becomes increasingly important. How do we control what a user can do?
In the next article, we will introduce permission management. Through systems like administrators, we will ensure the healthy development of the community. For example, banning a user from speaking.

