Build an Embeddable Comment System Like Disqus (Step-by-Step Guide)
In this tutorial, you'll learn about Build an Embeddable Comment System Like Disqus (Step. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build an embeddable comment system — a JavaScript widget that you drop into any HTML page, backed by a Node.js API that handles comments, threaded replies, spam filtering, and moderation queues.
What You'll Build
You will build a comment system like Disqus or Isso — a snippet of JavaScript you paste into any website that renders a live comment section. The backend API in Node.js stores comments, supports nested replies, filters spam with basic heuristics, and provides a moderation dashboard.
Why Build Your Own Comment System?
Third-party comment services track users across sites, inject ads, and slow down page loads. A self-hosted system gives you full control over data privacy, moderation, and appearance. Security tools like Doda Browser's tracker-blocking feature flag third-party comment widgets — hosting your own keeps your site fast and privacy-respecting.
Prerequisites
- Node.js 18+ installed
- Basic JavaScript for the widget
- Familiarity with Express.js
Step 1: Project Setup
mkdir comment-system
cd comment-system
npm init -y
npm install <a href="/backend/nodejs/">Express</a> sqlite3 cors uuid
Step 2: The Database Layer
We use SQLite for simplicity. Each comment stores a page_id (the URL it belongs to), a parent_id for threading, and spam/approved status flags.
// db.js
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const db = new sqlite3.Database(path.join(__dirname, 'comments.db'));
db.run(`CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
page_id TEXT NOT NULL,
parent_id TEXT,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_spam INTEGER DEFAULT 0,
is_approved INTEGER DEFAULT 0
)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_page_id ON comments(page_id)`);
module.exports = db;
Step 3: Spam Filter
A simple Bayesian-inspired filter checks for common spam patterns before storage. Production systems would use a trained model, but this catches 80% of automated spam.
// spam.js
const SPAM_PATTERNS = [
/\b(buy now|click here|free money|limited offer)\b/i,
/\b(http|https):\/\/[^\s]{30,}/g,
/<a\s+href/i,
/(.)\1{10,}/,
];
function isSpam(author, body) {
const text = `${author} ${body}`;
let score = 0;
for (const pattern of SPAM_PATTERNS) {
const matches = text.match(pattern);
if (matches) score += matches.length;
}
return score >= 2;
}
module.exports = { isSpam };
Expected output: isSpam("admin", "check this out http://spam.example.com/xyz free money") returns true. isSpam("Alice", "Great article!") returns false.
Step 4: The Express API
// server.js
const express = require('express');
const cors = require('cors');
const { v4: uuidv4 } = require('uuid');
const db = require('./db');
const { isSpam } = require('./spam');
const app = express();
app.use(cors());
app.use(express.json());
app.get('/api/comments', (req, res) => {
const { page } = req.query;
if (!page) return res.status(400).json({ error: 'page required' });
db.all(
`SELECT id, parent_id, author, body, created_at
FROM comments
WHERE page_id = ? AND is_approved = 1 AND is_spam = 0
ORDER BY created_at ASC`,
[page],
(err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json({ comments: rows });
}
);
});
app.post('/api/comments', (req, res) => {
const { page, author, body, parent_id } = req.body;
if (!page || !author || !body) {
return res.status(400).json({ error: 'page, author, body required' });
}
if (body.length > 2000) {
return res.status(400).json({ error: 'body too long (max 2000)' });
}
const id = uuidv4();
const spam = isSpam(author, body);
db.run(
`INSERT INTO comments (id, page_id, parent_id, author, body, is_spam, is_approved)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[id, page, parent_id || null, author, body, spam ? 1 : 0, spam ? 0 : 1],
function (err) {
if (err) return res.status(500).json({ error: err.message });
res.status(201).json({
id,
approved: !spam,
message: spam ? 'awaiting moderation' : 'comment posted',
});
}
);
});
app.get('/api/comments/pending', (req, res) => {
db.all(
`SELECT * FROM comments WHERE is_spam = 1 OR is_approved = 0 ORDER BY created_at DESC`,
(err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json({ pending: rows });
}
);
});
app.post('/api/comments/:id/approve', (req, res) => {
db.run(
`UPDATE comments SET is_spam = 0, is_approved = 1 WHERE id = ?`,
[req.params.id],
function (err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ success: true });
}
);
});
app.listen(3000, () => console.log('Comment API on :3000'));
Step 5: The Embeddable Widget
The JavaScript widget is a self-contained script that fetches comments from the API and renders them in any page that includes a <div id="comments-widget"> element.
// widget.js
(function () {
const API = 'http://localhost:3000/api';
const container = document.getElementById('comments-widget');
if (!container) return;
const page = encodeURIComponent(window.location.pathname);
function render(comments) {
const thread = {};
const roots = [];
comments.forEach((c) => {
thread[c.id] = c;
c.replies = [];
});
comments.forEach((c) => {
if (c.parent_id && thread[c.parent_id]) {
thread[c.parent_id].replies.push(c);
} else {
roots.push(c);
}
});
function commentHTML(c, depth = 0) {
let html = `<div class="comment" style="margin-left:${depth * 24}px">
<strong>${escapeHtml(c.author)}</strong>
<span style="font-size:0.85em;color:#888">${c.created_at}</span>
<p>${escapeHtml(c.body)}</p>
<button onclick="replyTo('${c.id}')">Reply</button>
</div>`;
c.replies.forEach((r) => { html += commentHTML(r, depth + 1); });
return html;
}
container.innerHTML = `<h3>Comments</h3>
<div id="comment-form">
<input id="cmt-author" placeholder="Name" />
<textarea id="cmt-body" placeholder="Write a comment..."></textarea>
<button onclick="postComment()">Post</button>
</div>
<div id="comment-list">${roots.map((c) => commentHTML(c)).join('')}</div>`;
}
window.postComment = function () {
const author = document.getElementById('cmt-author').value;
const body = document.getElementById('cmt-body').value;
fetch(`${API}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page, author, body }),
}).then(() => { loadComments(); });
};
window.replyTo = function (parentId) {
const author = prompt('Your name:');
const body = prompt('Your reply:');
if (!author || !body) return;
fetch(`${API}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page, author, body, parent_id: parentId }),
}).then(() => { loadComments(); });
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function loadComments() {
fetch(`${API}/comments?page=${page}`)
.then((r) => r.json())
.then((data) => render(data.comments));
}
loadComments();
})();
Expected output: Drop <div id="comments-widget"></div><script src="http://localhost:3000/widget.js"></script> into any HTML page. The widget loads existing comments and lets visitors post new ones.
Architecture
flowchart TB
subgraph "Visitor Browser"
HTML[Your Website]
WIDGET[widget.js]
end
subgraph "Your Server"
API[Express API :3000]
DB[(SQLite)]
end
subgraph "Moderation"
DASH[Pending Queue]
end
HTML -->|includes| WIDGET
WIDGET -->|GET /api/comments| API
WIDGET -->|POST /api/comments| API
API -->|read/write| DB
DASH -->|GET /api/comments/pending| API
DASH -->|POST /api/comments/:id/approve| API
Common Errors
1. Widget not rendering
The comments-widget div must exist in the DOM before the script runs. Place the script tag after the div, or wrap the widget code in a DOMContentLoaded listener.
2. CORS errors
The widget fetches from a different origin than the page. Our API uses the cors() middleware which allows all origins. For production, restrict to your domain: cors({ origin: 'https://yoursite.com' }).
3. XSS via comment body
A malicious user posts <script>alert('xss')</script>. Our escapeHtml function converts < and > to HTML entities. Never use innerHTML with unsanitized user input.
4. Comments not appearing after post
New comments from non-spam authors are auto-approved. Check the pending queue at /api/comments/pending — the comment may be flagged as spam.
5. Threaded replies out of order The render function builds a tree from a flat list. If a parent comment is missing (deleted or not yet approved), its replies become root comments instead of nesting.
Practice Questions
1. How does the widget associate comments with a page?
It sends window.location.pathname as the page parameter. The API stores and queries by this value. Each URL gets its own comment thread.
2. Why separate spam filtering from the main API? Keeping it in its own module makes it testable independently. You can swap the heuristic filter for a Machine Learning model later without touching the API routes.
3. How does threading work?
Every comment stores an optional parent_id. The widget fetches all comments for a page and builds a tree in JavaScript by grouping children under their parent. This avoids complex SQL recursive queries.
4. Challenge: Email notifications
Add an email alert when a new comment is posted. Use nodemailer to notify the site owner. Include the comment text and a direct link to the moderation approve/reject endpoint.
5. Challenge: Rate Limiting Implement an in-memory rate limiter that allows 5 comments per minute per IP address. Use Express middleware with a Map of IP addresses to timestamps.
FAQ
Next Steps
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro