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.