Full Stack Personal Blog 12: Nested Comment Design
cz2025.06.03 15:05

By designing a nested comment feature for posts using Prisma and GraphQL, you can learn how to handle recursive data structures, implement hierarchical comment relationships, and optimize queries for efficient data retrieval and updates.

1. Comment Logic

  • Comments support replies, with customizable nesting depth (default: 3 levels)
  • Comment authors and administrators have the right to delete
  • Click to load more comments

2. Comment Model Design

Add a Comment model to prisma.schema. The parent field represents the parent comment; if empty, it is a top-level comment. Sync the database with npx prisma migrate dev --name add_comment.

model Comment {
  id          String    @id @default(cuid())
  content     String
  postId      String
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  post        Post      @relation(fields: [postId], references: [id])
  createdById String
  createdBy   User      @relation(fields: [createdById], references: [id])
  parent      Comment?  @relation("CommentReplies", fields: [parentId], references: [id])
  parentId    String?
  replies     Comment[] @relation("CommentReplies")
}

3. Add Comment APIs in GraphQL

  • postComments: Display top-level comments for each post by default, with support for loading more
  • commentReplies: Query replies to a comment, with support for loading more
  • addComment: Add a new comment; if parentId is empty, it's a top-level comment
  • deleteComment: Deleting a parent comment also deletes its child comments
// graphql/types/Comment.ts
import { builder } from '../builder'

builder.prismaObject('Comment', {
  fields: (t: any) => ({
    id: t.exposeID('id'),
    content: t.exposeString('content'),
    post: t.relation('post'),
    createdBy: t.relation('createdBy'),
    createdAt: t.expose('createdAt', {
      type: 'DateTime',
    }),
    updatedAt: t.expose('updatedAt', {
      type: 'DateTime',
    }),
    parent: t.relation('parent', {
      nullable: true,
    }),
    replies: t.relation('replies', {
      nullable: true,
    }),
    replyCount: t.int({
      resolve: async (root: any, args: any, ctx: any) => {
        const count = await ctx.prisma.comment.count({
          where: { parentId: root.id },
        })
        return count
      },
    }),
    isTopLevel: t.expose('parent', {
      type: 'Boolean',
      resolve: (root: any) => !root.parent,
    }),
  }),
})

builder.queryType({
  fields: (t) => ({
    postComments: t.field({
      type: ['Comment'] as any,
      args: {
        postId: t.arg.id(),
        skip: t.arg.int({ defaultValue: 0 }),
        take: t.arg.int({ defaultValue: 10 }),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { postId, skip, take } = args
        const comments = await ctx.prisma.comment.findMany({
          where: { postId: postId, parentId: null }, // Only get top-level comments
          skip: skip || 0,
          take: take || 10,
          orderBy: {
            createdAt: 'desc',
          },
        })
        return comments
      },
    }),
    commentReplies: t.field({
      type: ['Comment'] as any,
      args: {
        commentId: t.arg.id(),
        skip: t.arg.int({ defaultValue: 0 }),
        take: t.arg.int({ defaultValue: 10 }),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { commentId, skip, take } = args
        const replies = await ctx.prisma.comment.findMany({
          where: { parentId: commentId },
          skip: skip || 0,
          take: take || 10,
          orderBy: {
            createdAt: 'desc',
          },
        })
        return replies
      },
    }),
  }),
})

builder.mutationType({
  fields: (t: any) => ({
    addComment: t.field({
      type: 'Comment' as any,
      args: {
        postId: t.arg.id(),
        content: t.arg.string(),
        parentId: t.arg.id({ nullable: true }), // Optional, if provided creates a reply
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { postId, content, parentId } = args
        const comment = await ctx.prisma.comment.create({
          data: { postId, content, createdById: ctx.user.id, parentId },
        })
        return comment
      },
    }),
    deleteComment: t.field({
      type: 'Comment' as any,
      args: {
        id: t.arg.id(),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { id } = args

        const comment = await ctx.prisma.comment.findUnique({
          where: { id },
        })
        if (!comment) {
          throw new Error('Comment not found')
        }
        if (comment.createdById !== ctx.user.id || ctx.user.role !== 'ADMIN') {
          throw new Error('You are not allowed to delete this comment')
        }
        // Delete the comment and all its child comments
        await ctx.prisma.comment.deleteMany({
          where: { OR: [{ id }, { parentId: id }] },
        })
        return true
      },
    }),
  }),
} as any)

4. Get Comment Data in Post API

  • commentCount: count the number of comments for a post
  • topLevelCommentsCount: number of top-level comments
  • When deleting a post, also delete its comments
// graphql/types/Post.ts

import prisma from '@/lib/prisma'
import { builder } from '../builder'

builder.prismaObject('Post', {
  fields: (t: any) => ({
    //...
    comments: t.relation('comments'),
    commentCount: t.int({
      resolve: async (parent: any, _args: any, ctx: any) => {
        const commentCount = await ctx.prisma.comment.count({
          where: {
            postId: parent.id,
          },
        })
        return commentCount
      },
    }),
    // Computed field: number of top-level comments (excluding replies)
    topLevelCommentsCount: t.int({
      resolve: async (parent: any, _args: any, ctx: any) => {
        return ctx.prisma.comment.count({
          where: {
            postId: parent.id,
            parentId: null,
          },
        })
      },
    }),
  }),
})

// Define mutation type for deleting a post
builder.mutationType({
  fields: (t: any) => ({
    deletePost: t.field({
      type: 'Post' as any,
      args: {
        id: t.arg.id(),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { id } = args

        // Delete Like records related to the Post
        await ctx.prisma.like.deleteMany({
          where: { postId: id },
        })
        // Delete Comment records related to the Post
        await ctx.prisma.comment.deleteMany({
          where: { postId: id },
        })

        // Then delete the Post
        await ctx.prisma.post.delete({
          where: { id },
        })

        return true
      },
    }),
  }),
} as any)

5. Frontend Comment Components

  • Top-level comment component
// CommentList.tsx
'use client'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { gql, useMutation, useQuery } from '@apollo/client'
import React, { useEffect, useState } from 'react'
import CommentItem from './CommentItem'
import { Loader, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { useSearchParams } from 'next/navigation'
// Create comment mutation
const ADD_COMMENT = gql`
  mutation AddComment($content: String!, $postId: ID!, $parentId: ID) {
    addComment(content: $content, postId: $postId, parentId: $parentId) {
      id
    }
  }
`

const GET_COMMENTS = gql`
  query GetComments($postId: ID!, $skip: Int, $take: Int) {
    postComments(postId: $postId, skip: $skip, take: $take) {
      id
      content
      createdAt
      createdBy {
        id
        nickname
      }
      replyCount
    }
  }
`

type Props = {
  post: any
  currentUserId?: string
}

const COMMENTS_PER_PAGE = 10

const CommentList = ({ post, currentUserId }: Props) => {
  const [newComment, setNewComment] = useState('')
  const [commentsCount, setCommentsCount] = useState(post.topLevelCommentsCount)
  const {
    data: comments,
    loading,
    error,
    refetch: refetchComments,
    fetchMore,
  } = useQuery(GET_COMMENTS, {
    variables: {
      postId: post.id,
      skip: 0,
      take: COMMENTS_PER_PAGE,
    },
    notifyOnNetworkStatusChange: true,
  })

  const [addComment, { loading: addingComment }] = useMutation(ADD_COMMENT, {
    onCompleted: (data) => {
      setNewComment('')
      refetchComments()
      setCommentsCount((prev: number) => prev + 1)
    },
  })

  const handleCommentDeleted = () => {
    setCommentsCount((prev: number) => prev - 1)
    refetchComments()
  }

  const handleSubmitComment = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    if (!currentUserId) {
      toast.info('Please login to comment')
      return
    }
    if (!newComment.trim()) return

    await addComment({
      variables: {
        content: newComment.trim(),
        postId: post.id,
        parentId: null, // Create top-level comment
      },
    })
  }

  const handleLoadMoreComments = async () => {
    await fetchMore({
      variables: {
        skip: comments?.postComments.length ?? 0,
        take: COMMENTS_PER_PAGE,
      },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev
        return {
          postComments: [...prev.postComments, ...fetchMoreResult.postComments],
        }
      },
    })
  }

  const searchParams = useSearchParams()
  useEffect(() => {
    const scrollTo = searchParams.get('scrollTo')
    if (scrollTo === 'comments') {
      const commentsElement = document.getElementById('comments')
      if (commentsElement)
        commentsElement.scrollIntoView({ behavior: 'smooth' })
    }
  }, [searchParams])

  return (
    <div className="mt-8 space-y-4">
      <h4 className="font-semibold" id="comments">
        Comments
      </h4>
      {/* Comment form */}
      <form onSubmit={handleSubmitComment} className="max-w-[800px] space-y-2">
        <Textarea
          placeholder="Enter your comment..."
          value={newComment}
          onChange={(e) => setNewComment(e.target.value)}
          rows={3}
        />
        <Button
          type="submit"
          disabled={addingComment || !newComment.trim()}
          size="sm"
          className="cursor-pointer"
        >
          {addingComment ? 'Submitting...' : 'Submit'}
        </Button>
      </form>
      {/* Comment list */}
      {loading && (
        <div className="flex justify-center">
          <Loader className="h-4 w-4 animate-spin" />
        </div>
      )}
      <div className="space-y-4">
        {comments?.postComments.map((comment: any) => (
          <CommentItem
            key={comment.id}
            comment={comment}
            currentUserId={currentUserId}
            onCommentDeleted={handleCommentDeleted}
            postId={post.id}
            level={0}
          />
        ))}
      </div>
      {commentsCount > comments?.postComments.length && !loading && (
        <div className="flex justify-center pt-4">
          <Button
            variant="outline"
            size="sm"
            onClick={handleLoadMoreComments}
            className="cursor-pointer text-gray-500 hover:bg-gray-100"
          >
            load more comments
          </Button>
        </div>
      )}
    </div>
  )
}

export default CommentList
  • Child comment component, supports nesting
// CommentItem.tsx
import { Button } from '@/components/ui/button'
import {
  ChevronDown,
  ChevronUp,
  Loader,
  Reply,
  Send,
  Trash2,
  User,
} from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import React, { useEffect, useState } from 'react'
import { gql, useMutation, useQuery } from '@apollo/client'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'

type Props = {
  comment: any
  currentUserId?: string
  onCommentDeleted?: () => void
  level: number
  postId: string
}

const DELETE_COMMENT = gql`
  mutation DeleteComment($id: ID!) {
    deleteComment(id: $id) {
      id
    }
  }
`

const ADD_REPLY = gql`
  mutation AddReply($content: String!, $postId: ID!, $parentId: ID) {
    addComment(content: $content, postId: $postId, parentId: $parentId) {
      id
    }
  }
`

const GET_REPLIES = gql`
  query GetReplies($commentId: ID!, $skip: Int!, $take: Int!) {
    commentReplies(commentId: $commentId, skip: $skip, take: $take) {
      id
      content
      createdAt
      createdBy {
        id
        nickname
      }
      replyCount
    }
  }
`

const REPLIES_PER_PAGE = 10

const CommentItem = ({
  comment,
  currentUserId,
  onCommentDeleted,
  level,
  postId,
}: Props) => {
  console.log('comment---', comment)

  const isMaxLevel = level >= 2

  const [showReplies, setShowReplies] = useState(false)
  const [repliesCount, setRepliesCount] = useState(comment.replyCount)
  const {
    data: repliesData,
    refetch: refetchReplies,
    loading: repliesLoading,
    fetchMore,
  } = useQuery(GET_REPLIES, {
    variables: { commentId: comment.id, skip: 0, take: REPLIES_PER_PAGE },
    skip: !showReplies,
    notifyOnNetworkStatusChange: true,
  })

  // 加载更多回复
  const handleLoadMoreReplies = async () => {
    const currentReplies = repliesData?.commentReplies || []

    try {
      await fetchMore({
        variables: {
          commentId: comment.id,
          skip: currentReplies.length,
          take: REPLIES_PER_PAGE,
        },
        updateQuery: (prev, { fetchMoreResult }) => {
          if (!fetchMoreResult) return prev
          return {
            ...prev,
            commentReplies: [
              ...prev.commentReplies,
              ...fetchMoreResult.commentReplies,
            ],
          }
        },
      })
    } catch (error) {
      console.error('加载更多回复失败:', error)
    }
  }

  const [showReplyForm, setShowReplyForm] = useState(false)
  const [newReply, setNewReply] = useState('')

  const handleSubmitReply = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    await addReply({
      variables: {
        content: newReply,
        postId: postId,
        parentId: comment.id,
      },
    })
  }

  const [deleteComment, { loading: deletingComment }] = useMutation(
    DELETE_COMMENT,
    {
      onCompleted: (data) => {
        onCommentDeleted?.()
      },
    },
  )

  const handleCommentDeleted = () => {
    setRepliesCount((prev: number) => prev - 1)
    refetchReplies()
  }

  const [addReply, { loading: addingReply }] = useMutation(ADD_REPLY, {
    onCompleted: (data) => {
      setShowReplyForm(false)
      setNewReply('')
      refetchReplies()
      setRepliesCount((prev: number) => prev + 1)
    },
  })

  const handleDeleteComment = async (id: string) => {
    await deleteComment({
      variables: { id },
    })
  }

  return (
    <div
      key={comment.id}
      className={`${level > 0 ? 'border-l-1 border-gray-200 pl-4' : ''} border-b-1 border-gray-200 pb-3`}
    >
      <div className="flex h-8 items-center gap-8">
        <div className="flex items-center text-xs text-gray-500">
          <User className="mr-1 size-3" />
          <span>{comment.createdBy?.nickname || 'xxxx'}</span>
          <span className="mx-2"></span>
          <span>
            {formatDistanceToNow(new Date(comment.createdAt), {
              addSuffix: true,
            })}
          </span>
        </div>
        {comment.createdBy?.id === currentUserId && (
          <Button
            variant="ghost"
            size="sm"
            onClick={() => handleDeleteComment(comment.id)}
            disabled={deletingComment}
            className="cursor-pointer text-gray-500 hover:bg-gray-100"
          >
            <Trash2 className="size-3" />
          </Button>
        )}
      </div>
      <p className="mt-1">{comment.content}</p>
      <div className="mt-1 flex items-center gap-2">
        {!isMaxLevel && (
          <Button
            variant="ghost"
            size="sm"
            className="cursor-pointer text-gray-500"
            onClick={() => {
              if (!currentUserId) {
                toast.info('Please login to reply')
                return
              }
              setShowReplyForm(!showReplyForm)
            }}
          >
            <Reply className="size-3" />
          </Button>
        )}
        {repliesCount > 0 && (
          <div className="flex items-center gap-1">
            <Button
              variant="link"
              size="sm"
              className="cursor-pointer text-xs text-gray-500"
              onClick={() => setShowReplies(!showReplies)}
            >
              {repliesCount} replies
              {showReplies ? (
                <ChevronDown className="size-3" />
              ) : (
                <ChevronUp className="size-3" />
              )}
            </Button>
          </div>
        )}
      </div>
      <div className="ml-2">
        {showReplyForm && (
          <div className="">
            <form className="max-w-[800px]" onSubmit={handleSubmitReply}>
              <Textarea
                placeholder="Enter your reply..."
                name="reply"
                value={newReply}
                onChange={(e) => setNewReply(e.target.value)}
              />
              <div className="flex gap-2">
                <Button
                  variant="outline"
                  size="sm"
                  className="mt-2 cursor-pointer text-gray-500"
                  type="submit"
                  disabled={addingReply}
                >
                  {addingReply ? 'replying...' : 'reply'}
                </Button>
                <Button
                  variant="link"
                  size="sm"
                  className="mt-2 cursor-pointer text-gray-500"
                  onClick={() => setShowReplyForm(false)}
                >
                  cancel
                </Button>
              </div>
            </form>
          </div>
        )}
        {showReplies && (
          <div className="mt-2">
            {repliesLoading && (
              <div className="flex justify-center">
                <Loader className="h-4 w-4 animate-spin" />
              </div>
            )}
            {repliesData?.commentReplies.length > 0 && (
              <div className="replies">
                {repliesData?.commentReplies.map((reply: any) => (
                  <CommentItem
                    key={reply.id}
                    comment={reply}
                    currentUserId={currentUserId}
                    onCommentDeleted={handleCommentDeleted}
                    level={level + 1}
                    postId={postId}
                  />
                ))}
              </div>
            )}

            {repliesCount > repliesData?.commentReplies.length &&
              !repliesLoading && (
                <Button
                  variant="outline"
                  size="sm"
                  className="mt-2 text-gray-500"
                  onClick={handleLoadMoreReplies}
                >
                  load more replies
                </Button>
              )}
          </div>
        )}
      </div>
    </div>
  )
}

export default CommentItem

6. GraphQL Provider Supports Load More

Cache the query data for comment APIs: postComments and commentReplies

// ApolloProvider.tsx

// ...

// Function to create a new Apollo Client instance
function createApolloClient() {
  return new ApolloClient({
    cache: new InMemoryCache({
      typePolicies: {
        Query: {
          fields: {
            postComments: {
              keyArgs: ['id'],
              merge(existing = [], incoming, { args }) {
                if (!args?.skip || args.skip === 0) {
                  return incoming
                }

                const existingIds = new Set(
                  existing.map((item: any) => item.id),
                )
                const uniqueIncoming = incoming.filter(
                  (item: any) => !existingIds.has(item.id),
                )

                return [...existing, ...uniqueIncoming]
              },
            },
            commentReplies: {
              keyArgs: ['commentId'],
              merge(existing = [], incoming, { args }) {
                // If it's the first page or refetch, return new data directly
                if (!args?.skip || args.skip === 0) {
                  return incoming
                }

                // Create a Set to deduplicate by id
                const existingIds = new Set(
                  existing.map((item: any) => item.id),
                )
                const uniqueIncoming = incoming.filter(
                  (item: any) => !existingIds.has(item.id),
                )

                return [...existing, ...uniqueIncoming]
              },
            },
          },
        },
      },
    }),
    //...
  })
}

// ...

Comments