Documentation
How to Add Comments to Next.js Blog
On this page
In this guide, you'll learn how to add a beautiful, fully-featured comment section to your Next.js blog using Wisp. We'll build a complete comment system with features like:
- Email verification for commenters
- Nested replies support
- Website URL fields
- Email capture for building your mailing list
- Modern UI using shadcn/ui components
You can preview the end results in one of our blog post.
Introduction
Adding comments to a blog can significantly increase user engagement and build community around your content. While there are many third-party comment solutions available, they often come with drawbacks like:
- Heavy JavaScript bundles
- Inconsistent styling with your site
- Limited customization options
Wisp's comment system solves these issues by providing a lightweight, customizable solution that you can fully control. In fact, Wisp is the only CMS that has built-in comment features for blog.
Enabling Comments on Wisp
Before implementing comments on your blog, you need to configure the comment settings in your Wisp dashboard:
- Go to your Wisp dashboard and navigate to Account Settings > Comments
- Enable comments by toggling the "Enable Comments" switch
- Configure additional options:
- Allow URLs: Let commenters add their website links
- Allow Nested Comments: Enable replies to comments
- Sign-up Message: Add a custom message to capture emails for your mailing list
Note: By default, Wisp only uses emails for verification. To store and use commenter emails, you must add a sign-up message.
Guide for Next.js + shadcn + react-hook-form
Step 1: Install Dependencies
First, install the required dependencies:
npm install @hookform/resolvers @tanstack/react-query react-hook-form zod
Here's what each package does:
- zod: A TypeScript-first schema validation library that helps ensure your form data is correctly typed and validated. It provides a powerful way to define the shape and constraints of your form data.
- react-hook-form: A performant form management library for React that provides a simple way to handle forms with validation. It minimizes unnecessary re-renders and reduces boilerplate code compared to traditional form handling.
- @hookform/resolvers/zod: An adapter that allows react-hook-form to use Zod schemas for form validation. This integration enables you to use your Zod schemas directly with react-hook-form.
- @tanstack/react-query: A powerful data-fetching and state management library that handles caching, background updates, and optimistic updates. We use it to manage our API calls to the Wisp comment endpoints and keep the UI in sync.
Step 2: Install shadcn/ui Components
We'll use several shadcn/ui components to build our comment interface. These components provide a modern, accessible UI that's easy to customize.
Install the required components using the shadcn CLI:
npx shadcn@latest add button alert checkbox form input textarea toast
This will add the following components to your project:
Button
: For the submit buttonAlert
: For showing the verification messageCheckbox
: For the email usage consentForm
: For form handling and validationInput
: For name, email, and website fieldsTextarea
: For the comment contentToast
: For showing success/error messages
Make sure to also set up the toast hook by adding the Toaster component to your layout file:
// app/layout.tsx
import { Toaster } from "@/components/ui/toaster";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Toaster />
</body>
</html>
);
}
For more details on customizing these components, refer to the shadcn/ui documentation.
Step 3: Install and Configure Wisp SDK
First, install the Wisp JavaScript SDK:
npm install @wisp-cms/client
Create a new file lib/wisp.ts
to configure the Wisp client:
import { buildWispClient } from "@wisp-cms/client";
export const wisp = buildWispClient({
blogId: "my-blog-id", // Obtain this from Wisp's setup page
});
You can find your blogId
in your Wisp setup page. For more details about the SDK and its capabilities, check out the Wisp JavaScript SDK documentation.
Step 4: Create the Comment Components
Create three main components for the comment system:
- CommentSection.tsx - The main wrapper component:
"use client";
import { useQuery } from "@tanstack/react-query";
import { wisp } from "@/lib/wisp";
import { CommentForm } from "./CommentForm";
import { CommentList } from "./CommentList";
interface CommentSectionProps {
slug: string;
}
export function CommentSection({ slug }: CommentSectionProps) {
const { data, isLoading } = useQuery({
queryKey: ["comments", slug],
queryFn: () => wisp.getComments({ slug, page: 1, limit: "all" }),
});
if (isLoading) {
return <div>Loading...</div>;
}
if (!data?.config.enabled) {
return null;
}
return (
<div>
<h2 className="mb-8 text-2xl font-bold tracking-tight">Add Comments</h2>
<CommentForm slug={slug} config={data.config} />
<h2 className="mb-8 mt-16 text-2xl font-bold tracking-tight">Comments</h2>
<CommentList
comments={data.comments}
pagination={data.pagination}
config={data.config}
isLoading={isLoading}
/>
</div>
);
}
- CommentForm.tsx - The form for submitting new comments:
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { Shield } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { wisp } from "@/lib/wisp";
const formSchema = z.object({
author: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
url: z
.union([z.string().url("Please enter a valid URL"), z.string().max(0)])
.optional(),
content: z.string().min(1, "Comment cannot be empty"),
allowEmailUsage: z.boolean(),
});
interface CommentFormProps {
slug: string;
config: {
enabled: boolean;
allowUrls: boolean;
allowNested: boolean;
signUpMessage: string | null;
};
parentId?: string;
onSuccess?: () => void;
}
export function CommentForm({ slug, config, onSuccess }: CommentFormProps) {
const { toast } = useToast();
const { mutateAsync: createComment, data } = useMutation({
mutationFn: async (input: z.infer<typeof formSchema>) => {
return await wisp.createComment({
...input,
slug,
});
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
author: "",
email: "",
url: "",
content: "",
allowEmailUsage: false,
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
await createComment(values);
if (onSuccess) {
onSuccess();
}
form.reset();
} catch (e) {
if (e instanceof Error) {
toast({
title: "Error",
description: e.message,
variant: "destructive",
});
}
}
};
if (data?.success) {
return (
<Alert className="bg-muted border-none">
<AlertDescription className="space-y-2 text-center">
<Shield className="text-muted-foreground mx-auto h-10 w-10" />
<div className="font-medium">Pending email verification</div>
<div className="text-muted-foreground m-auto max-w-lg text-balance text-sm">
Thanks for your comment! Please check your email to verify your
email and post your comment. If you don't see it in your inbox,
please check your spam folder.
</div>
</AlertDescription>
</Alert>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="author"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Your name"
{...field}
className="focus-visible:ring-inset"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="you@example.com"
className="focus-visible:ring-inset"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{config.allowUrls && (
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Website (optional)</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://example.com"
className="focus-visible:ring-inset"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Comment</FormLabel>
<FormControl>
<Textarea
placeholder="Share your thoughts..."
className="min-h-[120px] resize-y focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-offset-0"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{config.signUpMessage && (
<FormField
control={form.control}
name="allowEmailUsage"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-md">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-sm font-normal">
{config.signUpMessage}
</FormLabel>
</div>
</FormItem>
)}
/>
)}
<div className="flex items-center justify-between pt-2">
<Button type="submit" disabled={form.formState.isSubmitting}>
Post Comment
</Button>
</div>
</form>
</Form>
);
}
- CommentList.tsx - The component for displaying comments:
"use client";
import { format } from "date-fns";
import Link from "next/link";
interface CommentListProps {
comments: {
id: string;
author: string;
content: string;
url?: string | null;
createdAt: Date;
parent?: {
id: string;
author: string;
content: string;
url?: string | null;
createdAt: Date;
} | null;
}[];
pagination: {
page: number;
limit: number | "all";
totalPages: number;
totalComments: number;
};
config: {
enabled: boolean;
allowUrls: boolean;
allowNested: boolean;
};
isLoading?: boolean;
}
export function CommentList({ comments, config, isLoading }: CommentListProps) {
if (isLoading) {
return <div className="animate-pulse">Loading comments...</div>;
}
if (comments.length === 0) {
return (
<div className="text-muted-foreground mt-8 text-center">
No comments yet. Be the first to comment!
</div>
);
}
return (
<div className="mt-10 space-y-8">
{comments.map((comment) => (
<div key={comment.id} className="space-y-4">
{comment.parent && (
<div className="ml-8 border-l-2 pl-4">
<div className="text-muted-foreground mb-2 text-sm">
In reply to {comment.parent.author}
</div>
<div className="text-muted-foreground whitespace-pre-line text-sm">
{comment.parent.content}
</div>
</div>
)}
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{config.allowUrls && comment.url ? (
<Link href={comment.url} target="_blank">
<span className="font-medium">{comment.author}</span>
</Link>
) : (
<span className="font-medium">{comment.author}</span>
)}
</div>
<div className="text-muted-foreground text-sm">
{format(new Date(comment.createdAt), "PPp")}
</div>
</div>
<div className="mt-2 whitespace-pre-line text-sm">
{comment.content}
</div>
</div>
</div>
))}
</div>
);
}
Step 5: Add the Comment Section to Your Blog Post
Add the comment section to your blog post page:
import { CommentSection } from "@/components/CommentSection";
export default function BlogPost({ params }: { params: { slug: string } }) {
return (
<article>
{/* Your blog post content */}
<CommentSection slug={params.slug} />
</article>
);
}
Step 6: Configure React Query
Make sure to wrap your application with the React Query provider:
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
That's it! You now have a fully functional comment system with:
- Email verification
- Website URLs (if enabled)
- Email capture (if configured)
- Modern UI with shadcn/ui components
- Form validation with zod
- Optimistic updates with React Query
The comment system will automatically respect your Wisp configuration settings and provide a seamless experience for your users.