Cara Membuat Fitur Komentar di Bawah Artikel dengan Next.js dan Cloudflare D1

Gambar: Membangun fitur komentar interaktif dan sistem diskusi bertingkat pada website menggunakan Next.js, Cloudflare D1, dan teknologi web modern
Membangun sebuah website artikel atau blog tidak hanya sebatas menyajikan informasi satu arah. Untuk menciptakan ekosistem yang hidup—sebuah wadah silaturahmi dan diskusi—Anda membutuhkan fitur komentar yang interaktif. Pengunjung harus bisa memberikan tanggapan, saling membalas (komentar bertingkat), hingga memberikan like atau unlike pada opini tertentu.
Dalam panduan ini, kita akan membahas secara tuntas (langkah demi langkah) cara membangun sistem komentar real-time menggunakan Next.js (App Router), Cloudflare D1 sebagai database serverless, dan mendeploy-nya melalui integrasi VS Code & GitHub.
Hebatnya lagi, kita akan mengimplementasikan teknik lazy loading agar skor Core Web Vitals (PageSpeed) Anda, khususnya Cumulative Layout Shift (CLS), tetap hijau dan sempurna!
Mari kita mulai perjalanan coding ini.
Langkah 1: Mempersiapkan Database di Cloudflare D1
Alih-alih mencampuradukkan data komentar ke dalam database utama (misalnya Prisma yang sudah didedikasikan untuk fitur lain), kita akan memisahkan beban kerja ini menggunakan Cloudflare D1. Ini adalah database SQL serverless di jaringan edge yang sangat cepat.
- Buka Dasbor Cloudflare Anda, navigasikan ke menu Workers & Pages > D1 SQL.
- Klik tombol Create Database, lalu beri nama (misalnya:
komentar-penaedukasi-db). - Setelah database dibuat, masuk ke tab Console di dalam database tersebut.
- Salin dan eksekusi kode SQL berikut untuk membuat tabel komentar dan sistem like:
-- Membuat tabel komentar utama dan balasan CREATE TABLE comments ( id TEXT PRIMARY KEY, article_id TEXT NOT NULL, user_id TEXT NOT NULL, user_name TEXT, user_avatar TEXT, content TEXT NOT NULL, parent_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Membuat tabel Like/Unlike untuk mencegah spam dari user yang sama CREATE TABLE comment_likes ( id TEXT PRIMARY KEY, comment_id TEXT NOT NULL, user_id TEXT NOT NULL, is_like BOOLEAN NOT NULL, UNIQUE(comment_id, user_id) ); -- Indexing agar loading komentar per artikel melesat cepat CREATE INDEX idx_comments_article ON comments(article_id);
Setelah sukses dieksekusi, masuk ke tab Settings pada database tersebut dan catat Database ID (UUID) yang berupa deretan panjang angka dan huruf.
Langkah 2: Menghubungkan D1 ke Proyek Next.js (Wrangler)
Buka proyek Anda di VS Code. Agar proyek Next.js Anda bisa berbicara dengan Cloudflare D1, kita perlu mendaftarkan binding-nya. Buka file wrangler.toml di root proyek Anda, dan tambahkan baris berikut di bagian paling bawah:
[[d1_databases]] binding = "DB_KOMENTAR" database_name = "komentar-penaedukasi-db" database_id = "MASUKKAN-UUID-ANDA-DI-SINI"
Ganti MASUKKAN-UUID-ANDA-DI-SINI dengan ID yang Anda catat pada Tahap 1.
Langkah 3: Membangun Backend API (Edge Runtime)
Kita membutuhkan dua jalur API (route): satu untuk mengambil dan mengirim teks komentar, dan satu lagi khusus untuk menangani klik tombol jempol (Like/Unlike).
Karena kita menggunakan ekosistem Cloudflare, kita wajib menggunakan runtime = 'edge' dan menambahkan TypeScript interfaces agar terhindar dari error tipe data unknown saat di-build.
1. API Utama Komentar (app/api/komentar/route.ts)
Buat file baru di rute tersebut dan masukkan kode ini:
import { NextResponse } from 'next/server';
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const articleId = searchParams.get('articleId');
if (!articleId) return NextResponse.json({ error: 'articleId diperlukan' }, { status: 400 });
try {
const db = process.env.DB_KOMENTAR as any;
// Mengambil komentar beserta total Like & Unlike menggunakan Subquery
const { results } = await db.prepare(
`SELECT
c.*,
(SELECT COUNT(*) FROM comment_likes WHERE comment_id = c.id AND is_like = 1) as likesCount,
(SELECT COUNT(*) FROM comment_likes WHERE comment_id = c.id AND is_like = 0) as dislikesCount
FROM comments c
WHERE c.article_id = ?
ORDER BY c.created_at DESC`
).bind(articleId).all();
return NextResponse.json({ comments: results });
} catch (error) {
return NextResponse.json({ error: 'Gagal mengambil komentar' }, { status: 500 });
}
}
interface CommentPayload {
articleId: string;
userId: string;
userName: string;
userAvatar?: string;
content: string;
parentId?: string | null;
}
export async function POST(request: Request) {
try {
const body = (await request.json()) as CommentPayload;
const { articleId, userId, userName, userAvatar, content, parentId } = body;
const db = process.env.DB_KOMENTAR as any;
const id = crypto.randomUUID();
await db.prepare(
`INSERT INTO comments (id, article_id, user_id, user_name, user_avatar, content, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).bind(id, articleId, userId, userName, userAvatar || null, content, parentId || null).run();
return NextResponse.json({ success: true, id });
} catch (error) {
return NextResponse.json({ error: 'Gagal menyimpan komentar' }, { status: 500 });
}
}
2. API Khusus Jempol (app/api/komentar/like/route.ts)
Buat file satu lagi untuk handler interaksi jempol:
import { NextResponse } from 'next/server';
export const runtime = 'edge';
interface LikePayload {
commentId: string;
userId: string;
isLike: boolean;
}
export async function POST(request: Request) {
try {
const { commentId, userId, isLike } = (await request.json()) as LikePayload;
const db = process.env.DB_KOMENTAR as any;
const existing = await db.prepare(
`SELECT is_like FROM comment_likes WHERE comment_id = ? AND user_id = ?`
).bind(commentId, userId).first();
if (existing) {
if (existing.is_like === (isLike ? 1 : 0)) {
await db.prepare(`DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?`).bind(commentId, userId).run();
} else {
await db.prepare(`UPDATE comment_likes SET is_like = ? WHERE comment_id = ? AND user_id = ?`).bind(isLike ? 1 : 0, commentId, userId).run();
}
} else {
const id = crypto.randomUUID();
await db.prepare(`INSERT INTO comment_likes (id, comment_id, user_id, is_like) VALUES (?, ?, ?, ?)`).bind(id, commentId, userId, isLike ? 1 : 0).run();
}
const likesCount = await db.prepare(`SELECT COUNT(*) as count FROM comment_likes WHERE comment_id = ? AND is_like = 1`).bind(commentId).first();
const dislikesCount = await db.prepare(`SELECT COUNT(*) as count FROM comment_likes WHERE comment_id = ? AND is_like = 0`).bind(commentId).first();
return NextResponse.json({ success: true, likes: likesCount.count, dislikes: dislikesCount.count });
} catch (error) {
return NextResponse.json({ error: 'Gagal memproses jempol' }, { status: 500 });
}
}
Langkah 4: Membangun Antarmuka (UI) React yang Reaktif
Sekarang, mari kita buat komponen frontend-nya. Komponen ini dirancang dengan konsep Optimistic UI—ketika pengguna menekan "Kirim", komentar akan langsung muncul di layar secara instan tanpa menunggu loading server, memberikan ilusi kecepatan yang luar biasa.
Buat atau update file components/commentsection.tsx Anda dengan kode lengkap berikut:
"use client";
import { useState, useEffect } from "react";
import { useSession, signIn } from "next-auth/react";
type CommentType = {
id: string;
initial: string;
avatarUrl?: string;
bgColor: string;
name: string;
time: string;
text: string;
likes: number;
dislikes: number;
parentId: string | null;
};
export default function CommentSection({ articleId }: { articleId: string }) {
const { data: session, status } = useSession();
const isLoggedIn = status === "authenticated";
const [commentText, setCommentText] = useState("");
const [comments, setComments] = useState<CommentType[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState("");
const maxChars = 1000;
// Fetch Data Awal
useEffect(() => {
const fetchComments = async () => {
try {
const res = await fetch(`/api/komentar?articleId=${articleId}`);
const data = (await res.json()) as { comments: any[] };
if (data.comments) {
const formattedComments = data.comments.map((c: any) => {
const words = (c.user_name || "User").trim().split(" ");
const init = words.length === 1 ? words[0].substring(0, 2).toUpperCase() : (words[0][0] + words[1][0]).toUpperCase();
return {
id: c.id,
initial: init,
avatarUrl: c.user_avatar,
bgColor: "#1e3a8a",
name: c.user_name,
time: new Date(c.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' }),
text: c.content,
likes: c.likesCount || 0,
dislikes: c.dislikesCount || 0,
parentId: c.parent_id
};
});
setComments(formattedComments);
}
} catch (error) {
console.error("Gagal memuat:", error);
} finally {
setIsLoading(false);
}
};
fetchComments();
}, [articleId]);
// Handle Draf pasca-login
useEffect(() => {
if (isLoggedIn && session?.user) {
const pendingComment = localStorage.getItem("pending_comment");
if (pendingComment) {
handlePostToDB(pendingComment, null);
localStorage.removeItem("pending_comment");
}
}
}, [isLoggedIn, session]);
// Fungsi Kirim ke Database
const handlePostToDB = async (text: string, parentId: string | null) => {
const googleName = session?.user?.name || "Akun Pengunjung";
const googleEmail = session?.user?.email || "anonymous";
const avatar = session?.user?.image || "";
const init = googleName.substring(0, 2).toUpperCase();
// Optimistic Update
const tempId = crypto.randomUUID();
const optimisticComment: CommentType = {
id: tempId, initial: init, avatarUrl: avatar || undefined, bgColor: "#1e3a8a",
name: googleName, time: "Baru saja", text: text, likes: 0, dislikes: 0, parentId: parentId
};
setComments((prev) => [optimisticComment, ...prev]);
if (parentId) { setReplyText(""); setReplyingTo(null); } else { setCommentText(""); }
try {
await fetch('/api/komentar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ articleId, userId: googleEmail, userName: googleName, userAvatar: avatar, content: text, parentId })
});
} catch (error) {
console.error("Gagal mengirim:", error);
}
};
const handleLikeToggle = async (commentId: string, isLike: boolean) => {
if (!isLoggedIn) { alert("Silakan login."); signIn("google"); return; }
const googleEmail = session?.user?.email;
if (!googleEmail) return;
try {
const res = await fetch('/api/komentar/like', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ commentId, userId: googleEmail, isLike })
});
const data = (await res.json()) as { success: boolean; likes: number; dislikes: number };
if (data.success) {
setComments(prev => prev.map(c => c.id === commentId ? { ...c, likes: data.likes, dislikes: data.dislikes } : c));
}
} catch (error) { console.error("Gagal like:", error); }
};
const handleKirim = () => {
if (commentText.trim() === "") return;
if (!isLoggedIn) {
if (window.confirm("Login terlebih dahulu untuk komentar.\nKlik OK untuk lanjut.")) {
localStorage.setItem("pending_comment", commentText); signIn("google");
}
return;
}
handlePostToDB(commentText, null);
};
const mainComments = comments.filter(c => !c.parentId);
const getReplies = (parentId: string) => comments.filter(c => c.parentId === parentId).reverse();
return (
<div className="w-full mt-10 font-sans">
<h3 className="text-xl font-bold mb-4">Komentar ({comments.length})</h3>
{/* Input Utama */}
<div className="bg-slate-50 dark:bg-slate-900 p-4 rounded-2xl mb-6">
<div className="bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl p-3 shadow-sm">
<textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} maxLength={maxChars} placeholder="Tulis Komentar..." className="w-full resize-none outline-none bg-transparent text-sm min-h-[60px]" />
<div className="flex justify-between items-center mt-2 border-t border-slate-100 pt-3">
<span className="text-xs text-slate-500"><strong>{maxChars - commentText.length}</strong> Karakter tersisa</span>
<button onClick={handleKirim} disabled={commentText.trim() === ""} className="bg-[#1e3a8a] text-white px-5 py-1.5 rounded-full text-sm">Kirim</button>
</div>
</div>
</div>
{/* Render Komentar */}
<div className="flex flex-col">
{isLoading ? <div className="text-center py-8 text-sm">Memuat komentar...</div> :
mainComments.map((comment) => (
<div key={comment.id} className="flex flex-col gap-2 py-5 border-b border-slate-100">
<div className="flex gap-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center text-white overflow-hidden" style={{ backgroundColor: comment.bgColor }}>
{comment.avatarUrl ? <img src={comment.avatarUrl} className="w-full h-full object-cover" /> : comment.initial}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-sm">{comment.name} <span className="text-xs font-normal text-slate-500">{comment.time}</span></h4>
<p className="text-sm mt-1.5">{comment.text}</p>
<div className="flex items-center gap-5 mt-3 text-slate-500">
<button onClick={() => handleLikeToggle(comment.id, true)} className="flex gap-1.5 hover:text-blue-600">👍 <span className="text-xs">{comment.likes}</span></button>
<button onClick={() => handleLikeToggle(comment.id, false)} className="flex gap-1.5 hover:text-red-600">👎 <span className="text-xs">{comment.dislikes}</span></button>
<button onClick={() => setReplyingTo(replyingTo === comment.id ? null : comment.id)} className="text-xs hover:text-foreground">Balas</button>
</div>
</div>
</div>
{/* Input Balasan */}
{replyingTo === comment.id && (
<div className="ml-16 mt-3 bg-slate-50 p-3 rounded-xl border">
<textarea value={replyText} onChange={(e) => setReplyText(e.target.value)} placeholder="Balas komentar..." className="w-full outline-none bg-transparent text-sm min-h-[50px]" />
<button onClick={() => handlePostToDB(replyText, comment.id)} className="mt-2 px-4 py-1.5 bg-[#1e3a8a] text-white text-xs rounded-full">Kirim Balasan</button>
</div>
)}
{/* Render Balasan */}
{getReplies(comment.id).map(reply => (
<div key={reply.id} className="flex gap-3 mt-4 ml-12 pl-4 border-l-2 border-slate-100">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white overflow-hidden" style={{ backgroundColor: reply.bgColor }}>
{reply.avatarUrl ? <img src={reply.avatarUrl} className="w-full h-full object-cover" /> : reply.initial}
</div>
<div>
<h4 className="font-bold text-xs">{reply.name}</h4>
<p className="text-sm mt-1">{reply.text}</p>
</div>
</div>
))}
</div>
))}
</div>
</div>
);
}
Langkah 5: Mencegah Layout Shift (CLS) di Halaman Artikel
Jika komponen interaktif di-render langsung, PageSpeed bisa menurun akibat Cumulative Layout Shift (CLS)—layar bergeser mendadak saat komponen muncul.
Solusinya, panggil komponen di app/post/[slug]/page.tsx menggunakan metode Dynamic Import dan sediakan cadangan ruang kosong (skeleton) sebesar min-h-[300px].
Tambahkan ini di bagian atas halaman artikel Anda:
import dynamic from 'next/dynamic'
// Memuat komponen secara dinamis dan menyediakan ruang untuk mencegah CLS
const CommentSection = dynamic(() => import('@/components/commentsection'), {
loading: () => (
<div className="w-full py-10 flex flex-col gap-4 animate-pulse min-h-[300px]">
<div className="h-6 w-32 bg-slate-200 dark:bg-slate-800 rounded"></div>
<div className="h-32 w-full bg-slate-100 dark:bg-slate-900 rounded-2xl"></div>
</div>
)
})
Lalu, panggil komponen di bawah konten artikel Anda dengan mengirimkan article.id:
<div className="px-4 md:px-0">
<hr className="my-10 border-t border-border opacity-60" />
{/* Kunci utama: Menghubungkan ID Artikel! */}
<CommentSection articleId={article.id} />
</div>
Langkah 6: Eksekusi Akhir (Deploy ke Cloudflare via GitHub)
Kini kode kita sudah sempurna! Saatnya mengirimnya ke server produksi. Buka terminal di VS Code Anda dan jalankan tiga perintah ajaib ini:
git add . git commit -m "Membangun sistem komentar interaktif dengan D1 dan mengatasi CLS" git push
⚠️ PENTING: Langkah Wajib di Dasbor Cloudflare
Setelah Anda melakukan push, ada satu langkah krusial agar komentar Anda tidak gagal tersimpan di tahap produksi:
- Buka Dasbor Cloudflare > Workers & Pages.
- Pilih nama proyek Pages Anda.
- Masuk ke tab Settings > Functions.
- Gulir ke bawah ke bagian D1 database bindings.
- Klik Add binding, isi Variable name dengan
DB_KOMENTAR, lalu pilih databasekomentar-penaedukasi-dbAnda di kolom sebelah kanannya. - Save, dan lakukan deploy ulang (jika diperlukan).
Selamat! Website Next.js Anda kini memiliki ruang diskusi bertingkat sekelas platform modern. Dengan memanfaatkan Cloudflare D1 sebagai arsitektur serverless, interaksi menjadi sangat responsif tanpa membebani server utama, dan teknik lazy loading yang kita gunakan menjamin skor kecepatan SEO (PageSpeed) Anda tidak tergores sedikit pun!


