Skip to content

Build an Embeddable Comment System Like Disqus (Step-by-Step Guide)

DodaTech Updated 2026-06-21 7 min read

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

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

Is this GDPR compliant?

SQLite stores data locally on your server — no third-party data sharing. Add a cookie consent banner if you use cookies for Rate Limiting. Provide a data deletion endpoint to comply with right-to-erasure requests.

Can I use this with static site generators?

Yes. The widget is a self-contained script, so it works with Hugo, Jekyll, Eleventy, or plain HTML. Drop the div and script tags into your template and it works.

How do I migrate from Disqus?

Disqus allows you to export comments as XML. Write a script to parse the XML and insert each comment into the SQLite database using the POST API. The created_at field preserves original timestamps.

Next Steps

  • Add reCAPTCHA integration to block bots
  • Implement Markdown support in comment bodies using a library like marked
  • Build a moderation dashboard with React or vanilla HTML
  • Explore WebSocket for real-time comment updates across open browser tabs

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro