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:
typescript
import {CommentIcon} from'@sanity/icons'import {defineField, defineType} from'sanity'exportconst 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) returntrue; // Top-level comment is fineconst {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.';
}
returntrue;
} 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: () =>newDate().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';
consttype = 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:
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:
typescript
'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'// TypesinterfaceCreateCommentData {
postId: stringparentId?: stringname: stringemail: stringbody: string
}
interfaceCommentResult {
success: booleanmessage: stringcommentId?: stringerror?: string
}
interfaceCommentFormState {
success?: booleanerror?: stringmessage?: string
}
/**
* Create a new comment or reply
*/exportasyncfunctioncreateComment(data: CreateCommentData): Promise<CommentResult> {
try {
const { postId, parentId, name, email, body } = data
// Input validationif (!postId || !name || !email || !body) {
return {
success: false,
message: 'All fields are required'
}
}
// Email validationconst emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/if (!emailRegex.test(email)) {
return {
success: false,
message: 'Please enter a valid email address'
}
}
// Content length validationif (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 commentif (parentId) {
const parentComment = awaitsanityFetch({
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 documentconst 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 commentscreatedAt: newDate().toISOString(),
isSpam: false,
replyCount: 0
}
// Create the commentconst result = await client.create(commentDoc)
// If this is a reply, increment parent's reply countif (parentId) {
await client
.patch(parentId)
.inc({ replyCount: 1 })
.commit()
}
// Revalidate comments cacherevalidateTag(`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)
*/exportasyncfunctiongetAllComments(postId: string) {
try {
const comments = awaitsanityFetch({
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
*/exportasyncfunctioncreateCommentAction(prevState: CommentFormState | null,
formData: FormData): Promise<CommentFormState> {
try {
const postId = formData.get('postId') asstringconst parentId = formData.get('parentId') asstring || undefinedconst name = formData.get('name') asstringconst email = formData.get('email') asstringconst body = formData.get('body') asstring// Basic validationif (!postId || !name?.trim() || !email?.trim() || !body?.trim()) {
return {
error: 'All fields are required'
}
}
// Call the existing createComment functionconst result = awaitcreateComment({
postId,
parentId,
name: name.trim(),
email: email.trim().toLowerCase(),
body: body.trim()
})
if (result.success) {
// Revalidate the current page to show new commentrevalidatePath(`/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.
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.
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:
typescript
import { Suspense } from'react'import { Comments } from'@/components/comments/Comments'import { CommentsSkeleton } from'@/components/comments/CommentsSkeleton'import { getAllComments } from'@/actions/comments'exportdefaultasyncfunctionBlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
// Fetch your blog post hereconst post = awaitgetPost(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 */}
<divclassName="mt-12 pt-8 border-t"><Suspensefallback={<CommentsSkeleton />}>
<CommentspostId={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.
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:
Since we're now using cookies, we need to make the blog post page dynamic:
typescript
// Force dynamic rendering for cookie supportexportconst dynamic = 'force-dynamic'exportdefaultasyncfunctionBlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = awaitgetPost(slug)
// Create comments promise for React 19 streamingconst commentsPromise = getAllComments(post._id)
// Get saved commenter identity for form pre-fillingconst commenterIdentity = awaitgetCommenterIdentity()
return (
<article>
{/* Your blog post content */}
{/* Comments Section */}
<divclassName="mt-12 pt-8 border-t"><Suspensefallback={<CommentsSkeleton />}>
<CommentspostId={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:
typescript
import { clearCommenterIdentity, typeCommenterIdentity } from'@/actions/commenter-identity'interfaceCommentFormProps {
postId: stringparentId?: stringparentAuthor?: stringdefaultIdentity?: CommenterIdentity | nullonSuccess?: () =>voidonCancel?: () =>voidclassName?: string
}
exportfunctionCommentForm({
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 ...consthandleClearIdentity = async () => {
awaitclearCommenterIdentity()
setIdentityCleared(true)
// Clear the form fieldsif (formRef.current) {
const nameInput = formRef.current.querySelector('input[name="name"]') asHTMLInputElementconst emailInput = formRef.current.querySelector('input[name="email"]') asHTMLInputElementif (nameInput) nameInput.value = ''if (emailInput) emailInput.value = ''
}
}
return (
<CardclassName={cn('w-full', className)}>
{/* ... existing header ... */}
<CardContent>
{/* ... existing alerts ... */}
<formref={formRef}action={action}className="space-y-6">
{/* Hidden Fields */}
<inputname="postId"value={postId}type="hidden" />
{parentId && <inputname="parentId"value={parentId}type="hidden" />}
{/* Name and Email Row */}
<divclassName="grid grid-cols-1 md:grid-cols-2 gap-4"><divclassName="space-y-2"><LabelhtmlFor="name">
Name <spanclassName="text-red-500">*</span></Label><Inputid="name"name="name"type="text"placeholder={isPreFilled ? "Yourname (saved)" : "Yourname"}
defaultValue={isPreFilled ? defaultIdentity?.name || '' : ''}
requireddisabled={pending}
/></div><divclassName="space-y-2"><LabelhtmlFor="email">
Email <spanclassName="text-red-500">*</span></Label><Inputid="email"name="email"type="email"placeholder={isPreFilled ? "Email (saved)" : "your@email.com"}
defaultValue={isPreFilled ? defaultIdentity?.email || '' : ''}
requireddisabled={pending}
/><pclassName="text-xs text-gray-500">
Your email will not be published
</p></div></div>
{/* Identity Status */}
{isPreFilled && (
<divclassName="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-md"><divclassName="flex items-center gap-2 text-sm text-green-700"><CheckCircleclassName="h-4 w-4" /><span>
Welcome back! Using saved identity from {defaultIdentity?.commentCount || 0} previous comment{(defaultIdentity?.commentCount || 0) !== 1 ? 's' : ''}
</span></div><Buttontype="button"variant="ghost"size="sm"onClick={handleClearIdentity}disabled={pending}className="h-auto p-1 text-green-700 hover:text-green-800"
><XclassName="h-4 w-4" /><spanclassName="sr-only">Clear saved identity</span></Button></div>
)}
{/* ... rest of form ... */}
{/* Updated Guidelines */}
<divclassName="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:
// Optional user referencedefineField({
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.