How can I render unlimited nested replies (Reddit-style) in a single Django template?

I’m building a Reddit-style comment system with Django, and need users to be able to reply not only to a post but also to any comment, indefinitely deep.

My goal in here is when a user replies to a reply, that reply should itself be commentable, forming a true tree—not just two levels. With below code: Top-level comments display fine, direct replies to a top-level comment show up, but replies to those replies never appear even though they’re saved correctly in the database.

I’d love to keep it clean and avoid infinite {% for %} nesting. Is there a recommended Django-template pattern (perhaps a recursive template tag or custom inclusion tag) that works without breaking CSRF for the reply forms?

My model:

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL, 
        on_delete=models.CASCADE, 
        related_name="comments"
        )
    up_vote = models.BigIntegerField(blank=True, null=True)
    down_vote = models.BigIntegerField(blank=True, null=True)
    parent = models.ForeignKey(
        "self", null=True, blank=True, on_delete=models.CASCADE, related_name="replies"
    )
    body = models.TextField()
    created_at = models.DateTimeField(default=timezone.now)

    class Meta:
        ordering = ["created_at"]

    def __str__(self):
        return f"Comment by {self.author} on {self.post}"

my views:

@login_required
def post_details(request, slug):
    post = get_object_or_404(Post, slug=slug)
    community = post.community

    # Handle comment or reply POST
    if request.method == "POST":
        form = CommentForm(request.POST)
        if form.is_valid():
            parent_id = request.POST.get("parent_id")   # <-- may be blank
            parent = Comment.objects.filter(id=parent_id).first() if parent_id else None
            Comment.objects.create(
                post=post,
                author=request.user,
                body=form.cleaned_data['body'],
                parent=parent
            )
            return redirect('post_detail', slug=slug)
    else:
        form = CommentForm()

    comments = (
        Comment.objects
        .filter(post=post, parent__isnull=True)
        .select_related('author')
        .prefetch_related('replies__author')
    )
    is_member = community.members.filter(id=request.user.id).exists()

    return render(
        request,
        "post_detail.html",
        {
            "post": post,
            "comments": comments,
            "form": form,
            "community": community,
            "is_member": is_member,
            "members_count": community.members.count(),
        },
    )

template:

<section id="comments" class="mt-5">
  <h5 class="mb-4">{{ comments.count }} Comments</h5>

  {% if user.is_authenticated %}
    <!-- New top-level comment -->
    <form method="post" class="mb-4 d-flex gap-2">
      {% csrf_token %}
      <img src="{{ user.profile.avatar.url }}" class="rounded-circle"
           style="width:40px;height:40px;object-fit:cover;">
      <div class="flex-grow-1">
        {{ form.body }}
        <button type="submit" class="btn btn-sm btn-primary mt-2">Post Comment</button>
      </div>
    </form>
  {% else %}
    <p class="text-muted">Please <a href="{% url 'login' %}">login</a> to post a comment.</p>
  {% endif %}

  {% for comment in comments %}
    <div class="mb-4">
      <div class="d-flex">
        <img src="{{ comment.author.profile.avatar.url }}" class="rounded-circle me-2"
             style="width:40px;height:40px;object-fit:cover;">
        <div>
          <div class="fw-semibold text-light">{{ comment.author.username }}</div>
          <small class="text-muted">{{ comment.created_at|timesince }} ago</small>
          <p class="comment-body mt-1">{{ comment.body }}</p>
          <a href="#" class="text-info text-decoration-none reply-toggle"
             data-target="reply-form-{{ comment.id }}">Reply</a>

          <!-- Inline reply form (initially hidden) -->
          {% if user.is_authenticated %}
          <form method="post" class="mt-2 d-none" id="reply-form-{{ comment.id }}">
            {% csrf_token %}
            {{ form.body }}
            <input type="hidden" name="parent_id" value="{{ comment.id }}">
            <button type="submit" class="btn btn-sm btn-secondary mt-1">Reply</button>
          </form>
          {% endif %}

          <!-- Show replies recursively -->
          {% for reply in comment.replies.all %}
            <div class="d-flex mt-3 ms-4">
              <img src="{{ reply.author.profile.avatar.url }}" class="rounded-circle me-2"
                   style="width:32px;height:32px;object-fit:cover;">
              <div>
                <div class="fw-semibold text-light">{{ reply.author.username }}</div>
                <small class="text-muted">{{ reply.created_at|timesince }} ago</small>
                <p class="comment-body mt-1">{{ reply.body }}</p>
                <a href="#" class="text-info text-decoration-none reply-toggle"
                   data-target="reply-form-{{ reply.id }}">Reply</a>

                {% if user.is_authenticated %}
                <form method="post" class="mt-2 d-none" id="reply-form-{{ reply.id }}">
                  {% csrf_token %}
                  {{ form.body }}
                  <input type="hidden" name="parent_id" value="{{ reply.id }}">
                  <button type="submit" class="btn btn-sm btn-secondary mt-1">Reply</button>
                </form>
                {% endif %}
              </div>
            </div>
          {% endfor %}
        </div>
      </div>
    </div>
  {% empty %}
    <p class="text-muted">No comments yet. Be the first to comment!</p>
  {% endfor %}
</section>

The best way to achieve indefinitely deep, nested comments (a true tree structure) in Django templates without infinite {% for %} nesting and while preserving CSRF for all reply forms is to use a recursive template inclusion pattern.

My current implementation only iterates two levels deep (top-level and direct replies). The solution is to move the display logic for a single comment and its replies into a separate template that calls itself.

First of all I create a separate template file, for instance, comment_thread.html. This template will handle the display of a single comment item and then recursively include itself to display any replies to that item.

{% load static %}

<div class="d-flex mt-3 {% if is_reply %}ms-4{% endif %}">
    <img src="{{ comment.author.profile.avatar.url }}" class="rounded-circle me-2"
         style="width:{{ avatar_size }}px;height:{{ avatar_size }}px;object-fit:cover;">
    <div>
        <div class="fw-semibold text-light">{{ comment.author.username }}</div>
        <small class="text-muted">{{ comment.created_at|timesince }} ago</small>
        <p class="comment-body mt-1">{{ comment.body }}</p>

        <a href="#" class="text-info text-decoration-none reply-toggle"
           data-target="reply-form-{{ comment.id }}">Reply</a>

        {% if user.is_authenticated %}
            <form method="post" class="mt-2 d-none" id="reply-form-{{ comment.id }}">
                {% csrf_token %}
                {{ form.body }}
                <input type="hidden" name="parent_id" value="{{ comment.id }}">
                <button type="submit" class="btn btn-sm btn-secondary mt-1">Reply</button>
            </form>
        {% endif %}

        {% for reply in comment.replies.all %}
            {% include "comment_thread.html" with comment=reply is_reply=True form=form user=user avatar_size=32 %}
        {% endfor %}

    </div>
</div>

Secondly I do modify my main template to iterate only over the top-level comments and then initiate the recursion using the new include tag.

   <section id="comments" class="mt-5">
  <h5 class="mb-4">{{ comments.count }} Comments</h5>

  {% if user.is_authenticated %}
    <form method="post" class="mb-4 d-flex gap-2">
      {% csrf_token %}
      <img src="{{ user.profile.avatar.url }}" class="rounded-circle"
           style="width:40px;height:40px;object-fit:cover;">
      <div class="flex-grow-1">
        {{ form.body }}
        <button type="submit" class="btn btn-sm btn-primary mt-2">Post Comment</button>
      </div>
    </form>
  {% else %}
    <p class="text-muted">Please <a href="{% url 'login' %}">login</a> to post a comment.</p>
  {% endif %}

  {% for comment in comments %}
    {% include "comment_thread.html" with comment=comment is_reply=False form=form user=user avatar_size=40 %}
  {% empty %}
    <p class="text-muted">No comments yet. Be the first to comment!</p>
  {% endfor %}
</section>

This line {% include "comment_thread.html" with comment=reply is_reply=True form=form user=user avatar_size=32 %} is the core. It passes a reply object back to the same template, repeating the process for potentially infinite depth.

While the template now handles infinite depth, my current view only prefetches the first level of replies. For deeper threads, this will lead to an N+1 query problem, I think it will kill the performance.

@login_required
def post_details(request, slug):
    post = get_object_or_404(Post, slug=slug)
    community = post.community

    # Handle comment or reply POST
    if request.method == "POST":
        form = CommentForm(request.POST)
        if form.is_valid():
            parent_id = request.POST.get("parent_id")   # <-- may be blank
            parent = Comment.objects.filter(id=parent_id).first() if parent_id else None
            Comment.objects.create(
                post=post,
                author=request.user,
                body=form.cleaned_data['body'],
                parent=parent
            )
            return redirect('post_detail', slug=slug)
    else:
        form = CommentForm()

    comments = (
        Comment.objects
        .filter(post=post, parent__isnull=True)
        .select_related('author')
        # Use Prefetch to recursively fetch all replies
        .prefetch_related(
            'replies__author',
            'replies__replies__author',
            'replies__replies__replies__author',
            'replies__replies__replies__replies__author',
        )
    )
    is_member = community.members.filter(id=request.user.id).exists()

    return render(
        request,
        "post_detail.html",
        {
            "post": post,
            "comments": comments,
            "form": form,
            "community": community,
            "is_member": is_member,
            "members_count": community.members.count(),
        },
    )

For a truly robust solution in PostgreSQL, the ideal approach is a Recursive Common Table Expression (CTE) to fetch the entire tree in a single query. However, the recursive template approach combined with multi-level prefetching is the simplest and most framework-agnostic way to fix my immediate template problem.

Вернуться на верх