How to Add Comments to Next.js Blog

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:

Enable Comments in Settings

  1. Go to your Wisp dashboard and navigate to Account Settings > Comments
  2. Enable comments by toggling the "Enable Comments" switch
  3. 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 button
  • Alert: For showing the verification message
  • Checkbox: For the email usage consent
  • Form: For form handling and validation
  • Input: For name, email, and website fields
  • Textarea: For the comment content
  • Toast: 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:

  1. 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>
  );
}
  1. 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&apos;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>
  );
}
  1. 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.