Build a Custom Blog Commenting System with Next.js 15 and Sanity CMS

Take control of your blog's comments with a modern, extendable system built using Next.js 15 App Router, React Server Components, and Sanity.

·Matija Žiberna·
Build a Custom Blog Commenting System with Next.js 15 and Sanity CMS

I wanted to add commenting functionality to my personal blog. After researching various solutions like Disqus, Giscus, and other third-party services, I decided to build my own commenting system using Sanity CMS and Next.js. This approach gives me complete control over the data, user experience, and styling while keeping everything integrated with my existing tech stack.

In this guide, I'll walk you through building a complete commenting system from scratch. We'll start with basic commenting functionality and progressively enhance it with features like reply threading and persistent user identity across sessions.

What You'll Build

By the end of this tutorial, you'll have:

  • A fully functional commenting system integrated with Sanity CMS
  • Threaded replies (one level deep)
  • Auto-approved comments that appear immediately
  • Persistent commenter identity using server-side cookies
  • Modern React 19 features including the use() hook for data streaming
  • Server actions with useActionState for form handling
  • Responsive UI built with ShadCN components

Prerequisites

  • Next.js 15+ application
  • Sanity CMS project set up
  • Basic knowledge of React and TypeScript
  • ShadCN UI components installed

Part 1: Setting Up the Sanity Schema

First, we need to define how comments will be stored in Sanity. Our schema will include essential fields like commenter name, email, comment body, and references to the blog post and parent comment for threading. We'll also include moderation fields like approval status and spam detection. You're free to add additional fields like website URL, location, or any other metadata that suits your needs.

Creating the Comment Schema

Create src/sanity/schemaTypes/commentType.ts:

import {CommentIcon} from '@sanity/icons'
import {defineField, defineType} from 'sanity'

export const commentType = defineType({
  name: 'comment',
  title: 'Comment',
  type: 'document',
  icon: CommentIcon,
  fields: [
    defineField({
      name: 'name',
      title: 'Name',
      type: 'string',
      validation: (Rule) => Rule.required().min(2).max(100),
      description: 'Commenter\'s name'
    }),
    defineField({
      name: 'email',
      title: 'Email',
      type: 'string',
      validation: (Rule) => Rule.required().email(),
      description: 'Commenter\'s email (not displayed publicly)'
    }),
    defineField({
      name: 'body',
      title: 'Comment Body',
      type: 'text',
      validation: (Rule) => Rule.required().min(10).max(2000),
      description: 'The comment content'
    }),
    defineField({
      name: 'post',
      title: 'Post',
      type: 'reference',
      to: [{type: 'post'}],
      validation: (Rule) => Rule.required(),
      description: 'The blog post this comment belongs to'
    }),
    defineField({
      name: 'parent',
      title: 'Parent Comment',
      type: 'reference',
      to: [{type: 'comment'}],
      description: 'Reference to parent comment if this is a reply',
      validation: (Rule) => 
        Rule.custom(async (parent, context) => {
          if (!parent) return true; // Top-level comment is fine
          
          const {getClient} = context;
          const client = getClient({apiVersion: '2023-01-01'});
          
          try {
            const parentComment = await client.fetch(
              `*[_type == "comment" && _id == $parentId][0]{_id, parent, post}`,
              {parentId: parent._ref}
            );
            
            if (!parentComment) {
              return 'Parent comment does not exist';
            }
            
            // Prevent replies to replies (max 1 level deep)
            if (parentComment.parent) {
              return 'Replies to replies are not allowed. Maximum nesting depth is 1 level.';
            }
            
            return true;
          } catch (error) {
            return 'Error validating parent comment';
          }
        })
    }),
    defineField({
      name: 'approved',
      title: 'Approved',
      type: 'boolean',
      initialValue: true,
      description: 'Whether this comment is approved for public display'
    }),
    defineField({
      name: 'createdAt',
      title: 'Created At',
      type: 'datetime',
      initialValue: () => new Date().toISOString(),
      validation: (Rule) => Rule.required(),
      description: 'When the comment was submitted'
    }),
    defineField({
      name: 'isSpam',
      title: 'Marked as Spam',
      type: 'boolean',
      initialValue: false,
      description: 'Whether this comment has been marked as spam'
    }),
    defineField({
      name: 'replyCount',
      title: 'Reply Count',
      type: 'number',
      initialValue: 0,
      description: 'Number of approved replies to this comment',
      readOnly: true
    })
  ],
  preview: {
    select: {
      name: 'name',
      body: 'body',
      approved: 'approved',
      isSpam: 'isSpam',
      parent: 'parent',
      postTitle: 'post.title',
      createdAt: 'createdAt'
    },
    prepare(selection) {
      const {name, body, approved, isSpam, parent, postTitle, createdAt} = selection;
      const truncatedBody = body?.length > 100 ? `${body.slice(0, 100)}...` : body;
      const status = isSpam ? 'SPAM' : approved ? 'Approved' : 'Pending';
      const type = parent ? 'Reply' : 'Comment';
      
      return {
        title: `${type}: ${name}`,
        subtitle: `${status}${postTitle}${truncatedBody}`,
        media: CommentIcon
      };
    }
  }
})

This schema defines our comment structure with validation rules. Key points:

  • Reference to post: Links each comment to a blog post
  • Parent reference: Enables reply threading (limited to one level)
  • Auto-approval: Comments are approved by default for immediate display
  • Validation: Ensures data quality with length limits and required fields

Don't forget to add this schema to your Sanity configuration and run npm run generate:types to generate the corresponding TypeScript types.

I've written another article here on how to automatically generate TypeScript types using the Sanity V3 CLI—check it out!

Part 2: Creating Sanity Queries

Next, we need to create GROQ queries to fetch comment data from Sanity. GROQ is Sanity's query language, similar to GraphQL, that allows us to specify exactly what data we want. The defineQuery function automatically generates TypeScript types based on the query structure, giving us type safety throughout our application.

Create queries to fetch comments in src/sanity/lib/queries.ts:

import {defineQuery} from 'next-sanity'

export const COMMENTS_QUERY = defineQuery(`
  *[_type == "comment" && post._ref == $postId && approved == true && parent == null] | order(createdAt desc) {
    _id,
    name,
    body,
    createdAt,
    replyCount,
    "replies": *[_type == "comment" && parent._ref == ^._id && approved == true] | order(createdAt asc) {
      _id,
      name,
      body,
      createdAt
    }
  }
`)

export const COMMENT_COUNT_QUERY = defineQuery(`
  count(*[_type == "comment" && post._ref == $postId && approved == true])
`)

These queries handle fetching top-level comments with their nested replies and counting total comments for a post.

Part 3: Server Actions for Comment Management

Now we'll create server actions to handle comment operations. Server actions are essentially fetch requests in disguise - they allow us to run server-side code directly from client components without creating API routes. We're using server actions because they integrate seamlessly with React 19's useActionState hook and provide better developer experience with automatic form handling and validation.

We'll create several functions: createComment for basic comment creation with validation, getAllComments for fetching comment data, and createCommentAction specifically designed to work with React 19's form handling.

Create src/actions/comments.ts to handle comment operations:

'use server'

import { revalidatePath, revalidateTag } from 'next/cache'
import { sanityFetch } from '@/sanity/lib/client'
import { client } from '@/sanity/lib/client'
import { COMMENTS_QUERY, COMMENT_COUNT_QUERY } from '@/sanity/lib/queries'

// Types
interface CreateCommentData {
  postId: string
  parentId?: string
  name: string
  email: string
  body: string
}

interface CommentResult {
  success: boolean
  message: string
  commentId?: string
  error?: string
}

interface CommentFormState {
  success?: boolean
  error?: string
  message?: string
}

/**
 * Create a new comment or reply
 */
export async function createComment(data: CreateCommentData): Promise<CommentResult> {
  try {
    const { postId, parentId, name, email, body } = data

    // Input validation
    if (!postId || !name || !email || !body) {
      return {
        success: false,
        message: 'All fields are required'
      }
    }

    // Email validation
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(email)) {
      return {
        success: false,
        message: 'Please enter a valid email address'
      }
    }

    // Content length validation
    if (body.length < 10 || body.length > 2000) {
      return {
        success: false,
        message: 'Comment must be between 10 and 2000 characters'
      }
    }

    // If this is a reply, validate parent comment
    if (parentId) {
      const parentComment = await sanityFetch({
        query: `*[_type == "comment" && _id == $commentId][0]{_id, approved, parent, post}`,
        params: { commentId: parentId },
        tags: ['comment']
      })

      if (!parentComment) {
        return {
          success: false,
          message: 'Parent comment not found'
        }
      }

      if (!parentComment.approved) {
        return {
          success: false,
          message: 'Cannot reply to an unapproved comment'
        }
      }

      if (parentComment.parent) {
        return {
          success: false,
          message: 'Replies to replies are not allowed'
        }
      }

      if (parentComment.post?._ref !== postId) {
        return {
          success: false,
          message: 'Parent comment must belong to the same post'
        }
      }
    }

    // Create comment document
    const commentDoc = {
      _type: 'comment',
      name: name.trim(),
      email: email.toLowerCase().trim(),
      body: body.trim(),
      post: {
        _type: 'reference',
        _ref: postId
      },
      ...(parentId && {
        parent: {
          _type: 'reference',
          _ref: parentId
        }
      }),
      approved: true, // Auto-approve comments
      createdAt: new Date().toISOString(),
      isSpam: false,
      replyCount: 0
    }

    // Create the comment
    const result = await client.create(commentDoc)

    // If this is a reply, increment parent's reply count
    if (parentId) {
      await client
        .patch(parentId)
        .inc({ replyCount: 1 })
        .commit()
    }

    // Revalidate comments cache
    revalidateTag(`comments-${postId}`)

    return {
      success: true,
      message: 'Comment submitted successfully!',
      commentId: result._id
    }

  } catch (error) {
    console.error('Error creating comment:', error)
    return {
      success: false,
      message: 'Failed to submit comment. Please try again.'
    }
  }
}

/**
 * Get all comments for a post (with replies)
 */
export async function getAllComments(postId: string) {
  try {
    const comments = await sanityFetch({
      query: COMMENTS_QUERY,
      params: { postId },
      tags: [`comments-${postId}`, 'comment']
    })

    return comments || []
  } catch (error) {
    console.error('Error fetching all comments:', error)
    return []
  }
}

/**
 * Modern server action for useActionState
 */
export async function createCommentAction(
  prevState: CommentFormState | null,
  formData: FormData
): Promise<CommentFormState> {
  try {
    const postId = formData.get('postId') as string
    const parentId = formData.get('parentId') as string || undefined
    const name = formData.get('name') as string
    const email = formData.get('email') as string
    const body = formData.get('body') as string

    // Basic validation
    if (!postId || !name?.trim() || !email?.trim() || !body?.trim()) {
      return {
        error: 'All fields are required'
      }
    }

    // Call the existing createComment function
    const result = await createComment({
      postId,
      parentId,
      name: name.trim(),
      email: email.trim().toLowerCase(),
      body: body.trim()
    })

    if (result.success) {
      // Revalidate the current page to show new comment
      revalidatePath(`/blog/[slug]`, 'page')
      revalidateTag(`comments-${postId}`)
      
      return {
        success: true,
        message: result.message
      }
    } else {
      return {
        error: result.message
      }
    }

  } catch (error) {
    console.error('Error in createCommentAction:', error)
    return {
      error: 'Failed to submit comment. Please try again.'
    }
  }
}

The server actions handle comment creation with validation and use Next.js cache revalidation to update the UI. The createCommentAction function is specifically designed to work with React 19's useActionState hook.

Part 4: Comment Form Component

Now we move to the frontend to connect everything together. We'll create a comment form component that uses React 19's useActionState hook to handle form submission, validation, and loading states. This component will integrate with our server actions and provide a smooth user experience with real-time feedback.

comment form

Create src/components/comments/CommentForm.tsx:

'use client'

import React, { useActionState, useRef, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { createCommentAction } from '@/actions/comments'
import { cn } from '@/lib/utils'
import { Loader2, MessageSquare, Reply, CheckCircle } from 'lucide-react'

interface CommentFormProps {
  postId: string
  parentId?: string
  parentAuthor?: string
  onSuccess?: () => void
  onCancel?: () => void
  className?: string
}

export function CommentForm({
  postId,
  parentId,
  parentAuthor,
  onSuccess,
  onCancel,
  className
}: CommentFormProps) {
  const [state, action, pending] = useActionState(createCommentAction, null)
  const formRef = useRef<HTMLFormElement>(null)
  const router = useRouter()
  const isReply = !!parentId

  // Reset form and call onSuccess when comment is successfully created
  useEffect(() => {
    if (state?.success) {
      formRef.current?.reset()
      
      // Force immediate refresh to show the new comment
      setTimeout(() => {
        router.refresh()
      }, 100)
      
      onSuccess?.()
    }
  }, [state?.success, onSuccess, router])

  const handleCancel = () => {
    formRef.current?.reset()
    onCancel?.()
  }

  return (
    <Card className={cn('w-full', className)}>
      <CardHeader className="pb-4">
        <CardTitle className="flex items-center gap-2 text-lg">
          {isReply ? (
            <>
              <Reply className="h-4 w-4" />
              Reply to {parentAuthor}
            </>
          ) : (
            <>
              <MessageSquare className="h-4 w-4" />
              Leave a Comment
            </>
          )}
        </CardTitle>
      </CardHeader>
      
      <CardContent>
        {/* Success Message */}
        {state?.success && (
          <Alert className="mb-6 border-green-200 bg-green-50 text-green-800">
            <CheckCircle className="h-4 w-4" />
            <AlertDescription>{state.message}</AlertDescription>
          </Alert>
        )}

        {/* Error Message */}
        {state?.error && (
          <Alert variant="destructive" className="mb-6">
            <AlertDescription>{state.error}</AlertDescription>
          </Alert>
        )}

        <form ref={formRef} action={action} className="space-y-6">
          {/* Hidden Fields */}
          <input name="postId" value={postId} type="hidden" />
          {parentId && <input name="parentId" value={parentId} type="hidden" />}

          {/* Name and Email Row */}
          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
            <div className="space-y-2">
              <Label htmlFor="name">
                Name <span className="text-red-500">*</span>
              </Label>
              <Input
                id="name"
                name="name"
                type="text"
                placeholder="Your name"
                required
                disabled={pending}
                className="transition-colors"
              />
            </div>

            <div className="space-y-2">
              <Label htmlFor="email">
                Email <span className="text-red-500">*</span>
              </Label>
              <Input
                id="email"
                name="email"
                type="email"
                placeholder="your@email.com"
                required
                disabled={pending}
                className="transition-colors"
              />
              <p className="text-xs text-gray-500">
                Your email will not be published
              </p>
            </div>
          </div>

          {/* Comment Body */}
          <div className="space-y-2">
            <Label htmlFor="body">
              Comment <span className="text-red-500">*</span>
            </Label>
            <Textarea
              id="body"
              name="body"
              placeholder={isReply ? `Reply to ${parentAuthor}...` : "Share your thoughts..."}
              rows={4}
              required
              disabled={pending}
              className="resize-none transition-colors"
            />
            <p className="text-xs text-gray-500">
              10-2000 characters
            </p>
          </div>

          {/* Action Buttons */}
          <div className="flex flex-col sm:flex-row gap-3 pt-2">
            <Button
              type="submit"
              disabled={pending}
              className="flex-1 sm:flex-none"
            >
              {pending ? (
                <>
                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                  Submitting...
                </>
              ) : (
                <>
                  {isReply ? 'Post Reply' : 'Post Comment'}
                </>
              )}
            </Button>
            
            {onCancel && (
              <Button
                type="button"
                variant="outline"
                onClick={handleCancel}
                disabled={pending}
                className="flex-1 sm:flex-none"
              >
                Cancel
              </Button>
            )}
          </div>

          {/* Guidelines */}
          <div className="text-xs text-gray-500 space-y-1">
            <p>• Comments are automatically approved and will appear immediately</p>
            <p>• Be respectful and constructive in your feedback</p>
            <p>• No spam, self-promotion, or off-topic content</p>
          </div>
        </form>
      </CardContent>
    </Card>
  )
}

This form uses React 19's useActionState hook for modern form handling. Key features:

  • Progressive enhancement: Works without JavaScript
  • Real-time feedback: Shows loading states and error messages
  • Responsive design: Adapts to different screen sizes
  • Accessibility: Proper labeling and keyboard navigation

Part 5: Comment Display Components

Now we need to build components to display these comments. So far we've handled comment creation, but now we need to show existing comments and replies in a user-friendly way. We'll create two main components: CommentItem for individual comments with reply functionality, and Comments as the main container that uses React 19's use() hook to handle streamed data.

Commnet example

Create src/components/comments/Comments.tsx:

'use client'

import React, { use, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { CommentForm } from './CommentForm'
import { cn, formatDate } from '@/lib/utils'
import { MessageSquare, Reply, User, Clock, ChevronDown, ChevronUp } from 'lucide-react'

interface CommentsProps {
  postId: string
  commentsPromise: Promise<any[]>
  className?: string
}

interface CommentItemProps {
  comment: any
  postId: string
}

function CommentItem({ comment, postId }: CommentItemProps) {
  const [showReplyForm, setShowReplyForm] = useState(false)
  const [showReplies, setShowReplies] = useState(true)

  const handleReplyClick = () => {
    setShowReplyForm(!showReplyForm)
  }

  const handleReplySuccess = () => {
    setShowReplyForm(false)
    // Page will automatically refresh due to revalidatePath in server action
  }

  const handleReplyCancel = () => {
    setShowReplyForm(false)
  }

  const hasReplies = comment.replies && comment.replies.length > 0
  const replyCount = comment.replyCount || 0

  return (
    <div className="space-y-4">
      {/* Main Comment */}
      <Card className="w-full">
        <CardContent className="pt-6">
          {/* Comment Header */}
          <div className="flex items-start justify-between mb-4">
            <div className="flex items-center gap-3">
              <div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10">
                <User className="h-4 w-4 text-primary" />
              </div>
              <div>
                <div className="font-medium text-sm">{comment.name}</div>
                <div className="flex items-center gap-2 text-xs text-muted-foreground">
                  <Clock className="h-3 w-3" />
                  <time dateTime={comment.createdAt}>
                    {formatDate(comment.createdAt)}
                  </time>
                </div>
              </div>
            </div>
          </div>

          {/* Comment Body */}
          <div className="mb-4">
            <p className="text-sm leading-relaxed whitespace-pre-wrap">
              {comment.body}
            </p>
          </div>

          {/* Comment Actions */}
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-3">
              <Button
                variant="ghost"
                size="sm"
                onClick={handleReplyClick}
                className="h-8 px-3 text-xs"
              >
                <Reply className="h-3 w-3 mr-1" />
                Reply
              </Button>
              
              {replyCount > 0 && (
                <Badge variant="secondary" className="text-xs">
                  {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
                </Badge>
              )}
            </div>

            {hasReplies && (
              <Button
                variant="ghost"
                size="sm"
                onClick={() => setShowReplies(!showReplies)}
                className="h-8 px-3 text-xs"
              >
                {showReplies ? (
                  <>
                    <ChevronUp className="h-3 w-3 mr-1" />
                    Hide replies
                  </>
                ) : (
                  <>
                    <ChevronDown className="h-3 w-3 mr-1" />
                    Show replies
                  </>
                )}
              </Button>
            )}
          </div>
        </CardContent>
      </Card>

      {/* Reply Form */}
      {showReplyForm && (
        <div className="ml-8">
          <CommentForm
            postId={postId}
            parentId={comment._id}
            parentAuthor={comment.name || 'Anonymous'}
            onSuccess={handleReplySuccess}
            onCancel={handleReplyCancel}
            className="border-l-2 border-primary/20 pl-4"
          />
        </div>
      )}

      {/* Replies */}
      {hasReplies && showReplies && (
        <div className="ml-8 space-y-3">
          <Separator className="my-2" />
          {comment.replies?.map((reply: any) => (
            <Card key={reply._id} className="bg-muted/30">
              <CardContent className="pt-4">
                {/* Reply Header */}
                <div className="flex items-center gap-3 mb-3">
                  <div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary/10">
                    <User className="h-3 w-3 text-primary" />
                  </div>
                  <div>
                    <div className="font-medium text-xs">{reply.name}</div>
                    <div className="flex items-center gap-1 text-xs text-muted-foreground">
                      <Clock className="h-2 w-2" />
                      <time dateTime={reply.createdAt}>
                        {formatDate(reply.createdAt)}
                      </time>
                    </div>
                  </div>
                </div>

                {/* Reply Body */}
                <p className="text-xs leading-relaxed whitespace-pre-wrap pl-9">
                  {reply.body}
                </p>
              </CardContent>
            </Card>
          ))}
        </div>
      )}
    </div>
  )
}

export function Comments({ postId, commentsPromise, className }: CommentsProps) {
  // Use React 19's use() hook to unwrap the promise
  const comments = use(commentsPromise)
  const commentCount = comments?.length || 0

  return (
    <div className={cn('w-full space-y-8', className)}>
      {/* Header */}
      <div className="space-y-4">
        <div className="flex items-center gap-2">
          <MessageSquare className="h-5 w-5" />
          <h2 className="text-xl font-semibold">Comments</h2>
          {commentCount > 0 && (
            <Badge variant="secondary">
              {commentCount}
            </Badge>
          )}
        </div>
      </div>

      {/* Comment Form */}
      <CommentForm postId={postId} />

      {/* Comments List */}
      {comments && comments.length > 0 ? (
        <div className="space-y-6">
          <Separator />
          <div className="space-y-6">
            {comments.map((comment) => (
              <CommentItem
                key={comment._id}
                comment={comment}
                postId={postId}
              />
            ))}
          </div>
        </div>
      ) : (
        <div className="space-y-4">
          <Separator />
          <Card className="border-dashed">
            <CardContent className="pt-6">
              <div className="text-center py-8">
                <MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
                <h3 className="text-lg font-medium mb-2">No comments yet</h3>
                <p className="text-muted-foreground">
                  Be the first to share your thoughts!
                </p>
              </div>
            </CardContent>
          </Card>
        </div>
      )}
    </div>
  )
}

This component uses React 19's use() hook to handle the streamed data. This is a powerful new feature that allows components to unwrap promises directly, enabling true streaming from server to client.

Understanding the use() Hook

The use() hook is a React 19 feature that allows you to read the value of a promise inside a component. Instead of managing loading states manually, React handles the suspense boundary automatically. When the promise resolves, the component re-renders with the data.

Part 6: Integrating Comments into Blog Posts

Now comes the exciting part - integrating our comment system into actual blog posts! We'll modify the blog post page to include our comments component. We're adding a Suspense boundary around the comments to enable React 19's streaming capabilities, and creating a comments promise that starts loading immediately without blocking the page render.

Update your blog post page to include comments. In src/app/blog/[slug]/page.tsx:

import { Suspense } from 'react'
import { Comments } from '@/components/comments/Comments'
import { CommentsSkeleton } from '@/components/comments/CommentsSkeleton'
import { getAllComments } from '@/actions/comments'

export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  
  // Fetch your blog post here
  const post = await getPost(slug)
  
  // Create comments promise for React 19 streaming (don't await it)
  const commentsPromise = getAllComments(post._id)

  return (
    <article>
      {/* Your blog post content */}
      
      {/* Comments Section */}
      <div className="mt-12 pt-8 border-t">
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments 
            postId={post._id} 
            commentsPromise={commentsPromise}
          />
        </Suspense>
      </div>
    </article>
  )
}

The key here is creating the commentsPromise without awaiting it. This allows React to start streaming the comments data while the rest of the page renders.

Part 7: Adding Persistent User Identity

This next feature is an optional enhancement that significantly improves user experience. Now let's enhance our commenting system to remember users across sessions using server-side cookies. This eliminates the need for users to re-enter their information on subsequent visits.

Understanding Server-Side Cookies

In Next.js, cookies can only be read and written on the server side. This means:

  • We read cookies in server components or server actions
  • We cannot access cookies directly in client components
  • We must pass cookie data down to client components as props
  • Cookies are secure and cannot be manipulated by client-side JavaScript

Important note: When we introduce cookies, we lose the ability to statically render pages since cookies require a server environment to read and write. This means our blog post pages will need to be dynamically rendered, but the trade-off is worth it for the improved user experience.

Creating Identity Management

Create src/actions/commenter-identity.ts:

'use server'

import { cookies } from 'next/headers'

export interface CommenterIdentity {
  name: string
  email: string
  lastUsed: string
  commentCount?: number
}

const COMMENTER_COOKIE = 'commenter-identity'
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60 // 1 year in seconds

/**
 * Get the saved commenter identity from cookies
 */
export async function getCommenterIdentity(): Promise<CommenterIdentity | null> {
  try {
    const cookieStore = await cookies()
    const identityCookie = cookieStore.get(COMMENTER_COOKIE)
    
    if (!identityCookie?.value) {
      return null
    }
    
    const identity = JSON.parse(identityCookie.value) as CommenterIdentity
    
    // Validate the data structure
    if (!identity.name || !identity.email || !identity.lastUsed) {
      return null
    }
    
    return identity
  } catch (error) {
    console.error('Error reading commenter identity:', error)
    return null
  }
}

/**
 * Save commenter identity to cookies
 */
export async function saveCommenterIdentity(
  name: string, 
  email: string,
  incrementCount: boolean = true
): Promise<void> {
  try {
    const cookieStore = await cookies()
    
    // Get existing identity to preserve comment count
    const existing = await getCommenterIdentity()
    const currentCount = existing?.commentCount || 0
    
    const identity: CommenterIdentity = {
      name: name.trim(),
      email: email.trim().toLowerCase(),
      lastUsed: new Date().toISOString(),
      commentCount: incrementCount ? currentCount + 1 : currentCount
    }
    
    cookieStore.set(COMMENTER_COOKIE, JSON.stringify(identity), {
      maxAge: COOKIE_MAX_AGE,
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      path: '/'
    })
  } catch (error) {
    console.error('Error saving commenter identity:', error)
  }
}

/**
 * Clear the saved commenter identity
 */
export async function clearCommenterIdentity(): Promise<void> {
  try {
    const cookieStore = await cookies()
    cookieStore.delete(COMMENTER_COOKIE)
  } catch (error) {
    console.error('Error clearing commenter identity:', error)
  }
}

Updating the Comment Creation Action

We need to modify our existing comment creation action to save the commenter's identity after successfully creating a comment. Specifically, we're adding a call to saveCommenterIdentity() right after a successful comment creation, before the cache revalidation.

Modify src/actions/comments.ts to save identity when comments are created:

import { saveCommenterIdentity } from './commenter-identity'

export async function createCommentAction(
  prevState: CommentFormState | null,
  formData: FormData
): Promise<CommentFormState> {
  try {
    // ... existing validation code ...

    const result = await createComment({
      postId,
      parentId,
      name: name.trim(),
      email: email.trim().toLowerCase(),
      body: body.trim()
    })

    if (result.success) {
      // Save commenter identity for future use
      await saveCommenterIdentity(name.trim(), email.trim().toLowerCase(), true)
      
      // ... existing revalidation code ...
      
      return {
        success: true,
        message: result.message
      }
    }
    
    // ... rest of function ...
  }
}

Updating the Blog Post Page for Dynamic Rendering

Since we're now using cookies, we need to make the blog post page dynamic:

// Force dynamic rendering for cookie support
export const dynamic = 'force-dynamic'

export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  
  // Create comments promise for React 19 streaming
  const commentsPromise = getAllComments(post._id)
  
  // Get saved commenter identity for form pre-filling
  const commenterIdentity = await getCommenterIdentity()

  return (
    <article>
      {/* Your blog post content */}
      
      {/* Comments Section */}
      <div className="mt-12 pt-8 border-t">
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments 
            postId={post._id} 
            commentsPromise={commentsPromise}
            defaultIdentity={commenterIdentity}
          />
        </Suspense>
      </div>
    </article>
  )
}

Enhancing the Comment Form with Identity

We're modifying the comment form to accept and handle pre-filled identity data. Specifically, we're adding a defaultIdentity prop, updating the interface to include this new prop, implementing logic to pre-fill form fields when identity data exists, adding a clear identity feature with a visual indicator, and updating form placeholders to show when data is saved.

Saved identity for commenting

Update src/components/comments/CommentForm.tsx to handle pre-filled identity:

import { clearCommenterIdentity, type CommenterIdentity } from '@/actions/commenter-identity'

interface CommentFormProps {
  postId: string
  parentId?: string
  parentAuthor?: string
  defaultIdentity?: CommenterIdentity | null
  onSuccess?: () => void
  onCancel?: () => void
  className?: string
}

export function CommentForm({
  postId,
  parentId,
  parentAuthor,
  defaultIdentity,
  onSuccess,
  onCancel,
  className
}: CommentFormProps) {
  const [state, action, pending] = useActionState(createCommentAction, null)
  const [identityCleared, setIdentityCleared] = useState(false)
  const formRef = useRef<HTMLFormElement>(null)
  const router = useRouter()
  const isReply = !!parentId
  const isPreFilled = !identityCleared && !!defaultIdentity

  // ... existing useEffect ...

  const handleClearIdentity = async () => {
    await clearCommenterIdentity()
    setIdentityCleared(true)
    
    // Clear the form fields
    if (formRef.current) {
      const nameInput = formRef.current.querySelector('input[name="name"]') as HTMLInputElement
      const emailInput = formRef.current.querySelector('input[name="email"]') as HTMLInputElement
      if (nameInput) nameInput.value = ''
      if (emailInput) emailInput.value = ''
    }
  }

  return (
    <Card className={cn('w-full', className)}>
      {/* ... existing header ... */}
      
      <CardContent>
        {/* ... existing alerts ... */}

        <form ref={formRef} action={action} className="space-y-6">
          {/* Hidden Fields */}
          <input name="postId" value={postId} type="hidden" />
          {parentId && <input name="parentId" value={parentId} type="hidden" />}

          {/* Name and Email Row */}
          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
            <div className="space-y-2">
              <Label htmlFor="name">
                Name <span className="text-red-500">*</span>
              </Label>
              <Input
                id="name"
                name="name"
                type="text"
                placeholder={isPreFilled ? "Your name (saved)" : "Your name"}
                defaultValue={isPreFilled ? defaultIdentity?.name || '' : ''}
                required
                disabled={pending}
              />
            </div>

            <div className="space-y-2">
              <Label htmlFor="email">
                Email <span className="text-red-500">*</span>
              </Label>
              <Input
                id="email"
                name="email"
                type="email"
                placeholder={isPreFilled ? "Email (saved)" : "your@email.com"}
                defaultValue={isPreFilled ? defaultIdentity?.email || '' : ''}
                required
                disabled={pending}
              />
              <p className="text-xs text-gray-500">
                Your email will not be published
              </p>
            </div>
          </div>

          {/* Identity Status */}
          {isPreFilled && (
            <div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-md">
              <div className="flex items-center gap-2 text-sm text-green-700">
                <CheckCircle className="h-4 w-4" />
                <span>
                  Welcome back! Using saved identity from {defaultIdentity?.commentCount || 0} previous comment{(defaultIdentity?.commentCount || 0) !== 1 ? 's' : ''}
                </span>
              </div>
              <Button
                type="button"
                variant="ghost"
                size="sm"
                onClick={handleClearIdentity}
                disabled={pending}
                className="h-auto p-1 text-green-700 hover:text-green-800"
              >
                <X className="h-4 w-4" />
                <span className="sr-only">Clear saved identity</span>
              </Button>
            </div>
          )}

          {/* ... rest of form ... */}
          
          {/* Updated Guidelines */}
          <div className="text-xs text-gray-500 space-y-1">
            <p>• Comments are automatically approved and will appear immediately</p>
            <p>• Your name and email will be saved for future comments</p>
            <p>• Be respectful and constructive in your feedback</p>
            <p>• No spam, self-promotion, or off-topic content</p>
          </div>
        </form>
      </CardContent>
    </Card>
  )
}

Update the Comments Component

We need to update the Comments component to accept the identity data and pass it down to both the main comment form and reply forms. Specifically, we're adding defaultIdentity to the component props interface, passing it to the main CommentForm, and ensuring it's passed to the CommentItem components so reply forms can also benefit from pre-filled data.

Update src/components/comments/Comments.tsx to pass the identity to forms:

interface CommentsProps {
  postId: string
  commentsPromise: Promise<any[]>
  defaultIdentity?: CommenterIdentity | null
  className?: string
}

export function Comments({ postId, commentsPromise, defaultIdentity, className }: CommentsProps) {
  const comments = use(commentsPromise)
  const commentCount = comments?.length || 0

  return (
    <div className={cn('w-full space-y-8', className)}>
      {/* ... existing header ... */}

      {/* Comment Form */}
      <CommentForm postId={postId} defaultIdentity={defaultIdentity} />

      {/* Comments List */}
      {comments && comments.length > 0 ? (
        <div className="space-y-6">
          <Separator />
          <div className="space-y-6">
            {comments.map((comment) => (
              <CommentItem
                key={comment._id}
                comment={comment}
                postId={postId}
                defaultIdentity={defaultIdentity}
              />
            ))}
          </div>
        </div>
      ) : (
        // ... existing empty state ...
      )}
    </div>
  )
}

Testing Your Implementation

  1. Start your development server: npm run dev
  2. Navigate to a blog post with the comments section
  3. Submit a test comment - it should appear immediately
  4. Test replies by clicking the Reply button on a comment
  5. Refresh the page - your identity should be remembered
  6. Test the clear identity feature using the X button

Commenting admin

Future Enhancements

This basic commenting system provides a solid foundation for further enhancements:

Email Notifications

Add email notifications when replies are posted:

// In createComment function
if (parentId) {
  const parentComment = await getParentCommentWithEmail(parentId)
  await sendReplyNotification(parentComment.email, commentData)
}

Rich Text Support

Upgrade from plain text to markdown or rich text:

// Add to comment schema
defineField({
  name: 'bodyRich',
  title: 'Comment Body',
  type: 'array',
  of: [{type: 'block'}]
})

Social Login

Integrate OAuth providers for easier sign-in:

// Optional user reference
defineField({
  name: 'user',
  title: 'User',
  type: 'reference',
  to: [{type: 'user'}]
})

Spam Prevention

While we removed CAPTCHA for simplicity, consider adding:

  • Rate limiting: Prevent rapid-fire submissions
  • Content filtering: Basic keyword filtering
  • Manual moderation: Admin review for suspicious content

Conclusion

We've built a complete commenting system that provides a great user experience while maintaining simplicity. The system features:

  • React 19 patterns with streaming data and useActionState
  • Persistent user identity using secure server-side cookies
  • Responsive design with ShadCN components
  • Real-time updates with Next.js cache revalidation
  • Accessibility and progressive enhancement

The combination of Sanity CMS for data storage and Next.js for the frontend provides a powerful, scalable solution for adding comments to any blog or content site.

I hope this guide helps you implement your own commenting system. If you have questions or run into issues, please leave a comment below. This is the first article using this commenting system, so your feedback will help improve both the implementation and this documentation.

Thanks, — Matija

6

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in