Skip to main content
Back to Blog
Tutorials
4 min read
November 8, 2024

How to Build a Commenting System for Your Next.js Blog

Add a commenting system to your Next.js blog. Database-backed comments, nested replies, moderation, and spam protection.

Ryel Banfield

Founder & Lead Developer

A commenting system increases engagement and builds community. Here is how to build one from scratch in Next.js.

Step 1: Database Schema

Using Drizzle ORM with PostgreSQL:

// db/schema.ts
import { pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";

export const comments = pgTable("comments", {
  id: uuid("id").primaryKey().defaultRandom(),
  postSlug: text("post_slug").notNull(),
  parentId: uuid("parent_id"),
  authorName: text("author_name").notNull(),
  authorEmail: text("author_email").notNull(),
  content: text("content").notNull(),
  approved: boolean("approved").default(false),
  createdAt: timestamp("created_at").defaultNow(),
});

Step 2: API Routes

// app/api/comments/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { comments } from "@/db/schema";
import { eq, and, isNull, asc } from "drizzle-orm";
import { z } from "zod";

const commentSchema = z.object({
  postSlug: z.string().min(1),
  parentId: z.string().uuid().optional(),
  authorName: z.string().min(1).max(100),
  authorEmail: z.string().email(),
  content: z.string().min(1).max(2000),
});

// Get comments for a post
export async function GET(req: NextRequest) {
  const slug = req.nextUrl.searchParams.get("slug");
  if (!slug) {
    return NextResponse.json({ error: "Slug required" }, { status: 400 });
  }

  const results = await db
    .select()
    .from(comments)
    .where(and(eq(comments.postSlug, slug), eq(comments.approved, true)))
    .orderBy(asc(comments.createdAt));

  // Nest replies under parent comments
  const topLevel = results.filter((c) => !c.parentId);
  const nested = topLevel.map((parent) => ({
    ...parent,
    replies: results.filter((c) => c.parentId === parent.id),
  }));

  return NextResponse.json(nested);
}

// Submit a new comment
export async function POST(req: NextRequest) {
  const body = await req.json();
  const parsed = commentSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const { postSlug, parentId, authorName, authorEmail, content } = parsed.data;

  // Basic spam check
  if (containsSpam(content)) {
    return NextResponse.json({ error: "Spam detected" }, { status: 400 });
  }

  const [comment] = await db
    .insert(comments)
    .values({
      postSlug,
      parentId: parentId || null,
      authorName,
      authorEmail,
      content,
      approved: false, // Require moderation
    })
    .returning();

  return NextResponse.json({
    success: true,
    message: "Comment submitted for review.",
    comment,
  });
}

function containsSpam(text: string): boolean {
  const spamPatterns = [
    /buy now/i,
    /click here/i,
    /free money/i,
    /casino/i,
    /viagra/i,
  ];
  return spamPatterns.some((p) => p.test(text));
}

Step 3: Comment Form Component

"use client";

import { useState } from "react";

interface CommentFormProps {
  postSlug: string;
  parentId?: string;
  onSubmit?: () => void;
  onCancel?: () => void;
}

export function CommentForm({
  postSlug,
  parentId,
  onSubmit,
  onCancel,
}: CommentFormProps) {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [content, setContent] = useState("");
  const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
  const [error, setError] = useState("");

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setStatus("loading");
    setError("");

    try {
      const res = await fetch("/api/comments", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          postSlug,
          parentId,
          authorName: name,
          authorEmail: email,
          content,
        }),
      });

      if (!res.ok) {
        const data = await res.json();
        throw new Error(data.error || "Failed to submit");
      }

      setStatus("success");
      setName("");
      setEmail("");
      setContent("");
      onSubmit?.();
    } catch (err) {
      setStatus("error");
      setError(err instanceof Error ? err.message : "Something went wrong");
    }
  }

  if (status === "success") {
    return (
      <div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400">
        Comment submitted! It will appear after review.
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div className="grid grid-cols-2 gap-4">
        <div>
          <label className="mb-1 block text-sm font-medium">Name</label>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
            required
            className="w-full rounded-lg border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
          />
        </div>
        <div>
          <label className="mb-1 block text-sm font-medium">Email</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
            className="w-full rounded-lg border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
          />
        </div>
      </div>
      <div>
        <label className="mb-1 block text-sm font-medium">Comment</label>
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          required
          rows={4}
          maxLength={2000}
          className="w-full rounded-lg border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
        />
      </div>
      {error && <p className="text-sm text-red-600">{error}</p>}
      <div className="flex gap-2">
        <button
          type="submit"
          disabled={status === "loading"}
          className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
        >
          {status === "loading" ? "Submitting..." : "Post Comment"}
        </button>
        {onCancel && (
          <button
            type="button"
            onClick={onCancel}
            className="rounded-lg border px-4 py-2 text-sm"
          >
            Cancel
          </button>
        )}
      </div>
    </form>
  );
}

Step 4: Comment Display Component

"use client";

import { useState, useEffect } from "react";
import { CommentForm } from "./CommentForm";

interface Comment {
  id: string;
  authorName: string;
  content: string;
  createdAt: string;
  replies: Comment[];
}

export function CommentSection({ postSlug }: { postSlug: string }) {
  const [comments, setComments] = useState<Comment[]>([]);
  const [loading, setLoading] = useState(true);

  async function loadComments() {
    const res = await fetch(`/api/comments?slug=${postSlug}`);
    const data = await res.json();
    setComments(data);
    setLoading(false);
  }

  useEffect(() => {
    loadComments();
  }, [postSlug]);

  return (
    <section className="mt-12 border-t pt-8 dark:border-gray-700">
      <h2 className="mb-6 text-xl font-bold">Comments</h2>

      <CommentForm postSlug={postSlug} onSubmit={loadComments} />

      <div className="mt-8 space-y-6">
        {loading && <p className="text-sm text-gray-500">Loading comments...</p>}
        {!loading && comments.length === 0 && (
          <p className="text-sm text-gray-500">No comments yet. Be the first!</p>
        )}
        {comments.map((comment) => (
          <CommentThread
            key={comment.id}
            comment={comment}
            postSlug={postSlug}
            onReply={loadComments}
          />
        ))}
      </div>
    </section>
  );
}

function CommentThread({
  comment,
  postSlug,
  onReply,
}: {
  comment: Comment;
  postSlug: string;
  onReply: () => void;
}) {
  const [replying, setReplying] = useState(false);

  return (
    <div className="space-y-4">
      <div className="rounded-lg border p-4 dark:border-gray-700">
        <div className="mb-2 flex items-center gap-2">
          <div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-sm font-bold text-blue-600">
            {comment.authorName[0].toUpperCase()}
          </div>
          <div>
            <p className="text-sm font-medium">{comment.authorName}</p>
            <p className="text-xs text-gray-500">
              {new Date(comment.createdAt).toLocaleDateString()}
            </p>
          </div>
        </div>
        <p className="text-sm text-gray-700 dark:text-gray-300">
          {comment.content}
        </p>
        <button
          onClick={() => setReplying(!replying)}
          className="mt-2 text-xs text-blue-600 hover:underline"
        >
          Reply
        </button>
      </div>

      {replying && (
        <div className="ml-8">
          <CommentForm
            postSlug={postSlug}
            parentId={comment.id}
            onSubmit={() => {
              setReplying(false);
              onReply();
            }}
            onCancel={() => setReplying(false)}
          />
        </div>
      )}

      {comment.replies?.length > 0 && (
        <div className="ml-8 space-y-4">
          {comment.replies.map((reply) => (
            <div key={reply.id} className="rounded-lg border p-4 dark:border-gray-700">
              <div className="mb-2 flex items-center gap-2">
                <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-sm font-bold text-gray-600">
                  {reply.authorName[0].toUpperCase()}
                </div>
                <div>
                  <p className="text-sm font-medium">{reply.authorName}</p>
                  <p className="text-xs text-gray-500">
                    {new Date(reply.createdAt).toLocaleDateString()}
                  </p>
                </div>
              </div>
              <p className="text-sm text-gray-700 dark:text-gray-300">
                {reply.content}
              </p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Step 5: Admin Moderation API

// app/api/comments/[id]/approve/route.ts
export async function POST(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  // Verify admin auth here
  const { id } = await params;
  await db
    .update(comments)
    .set({ approved: true })
    .where(eq(comments.id, id));

  return NextResponse.json({ success: true });
}

Need a Custom Blog Platform?

We build blog platforms with commenting, moderation, and community features. Contact us to discuss your needs.

commentsblogNext.jsdatabasetutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles