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.
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")
}
postComments
: Display top-level comments for each post by default, with support for loading morecommentReplies
: Query replies to a comment, with support for loading moreaddComment
: Add a new comment; if parentId
is empty, it's a top-level commentdeleteComment
: 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)
commentCount
: count the number of comments for a posttopLevelCommentsCount
: number of top-level 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)
// 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
// 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
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]
},
},
},
},
},
}),
//...
})
}
// ...