Now that we understand the challenges, let’s explore different architectural approaches for building a comment system. Each architecture has trade-offs in complexity, cost, scalability, and features.
┌────────────────────────────────────────────────────────────────────────┐
│ ARCHITECTURE COMPARISON MATRIX │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Architecture Cost Complexity Scalability Latency │
│ ───────────────────────────────────────────────────────────────── │
│ Git-based Free Low Low High │
│ Serverless Low Medium High Medium │
│ Traditional API Medium Medium Medium Low │
│ Edge Functions Low Medium High Very Low │
│ Hybrid/JAMstack Low High High Low │
│ │
└────────────────────────────────────────────────────────────────────────┘
Store comments directly in your repository. Examples: Staticman, Utterances, Giscus.
┌─────────────────────────────────────────────────────────────────────────┐
│ GIT-BASED ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ │ │ │ │ │ │
│ │ User │──────│ Bot/API │──────│ Git │ │
│ │ Browser │ │ Service │ │ Repo │ │
│ │ │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ │ 1. Submit │ 2. Create PR │ │
│ │ Comment │ or Commit │ │
│ │ │ │ │
│ │ │ ┌──────┴──────┐ │
│ │ │ │ │ │
│ │ │ │ Build │ │
│ │ │ │ Trigger │ │
│ │ │ │ │ │
│ │ │ └──────┬──────┘ │
│ │ │ │ │
│ │ │ ┌──────┴──────┐ │
│ │ │ │ │ │
│ │ │ │ Static │ │
│ │ │ │ Site Gen │ │
│ │ │ │ │ │
│ │ │ └──────┬──────┘ │
│ │ │ │ │
│ │ │ ▼ │
│ │ │ ┌──────────────┐ │
│ │ │ │ │ │
│ │◄────────────────────┼──────────────│ Deploy │ │
│ │ 3. See comment │ │ (CDN) │ │
│ │ on next build │ │ │ │
│ │ │ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
File structure:
your-site/
├── content/
│ └── posts/
│ └── my-article.md
└── data/
└── comments/
└── my-article/
├── comment-1.yml
├── comment-2.yml
└── comment-3.yml
Comment file format:
# data/comments/my-article/comment-1.yml
_id: "a1b2c3d4"
name: "John Doe"
email_hash: "5d41402abc4b2a76b9719d911017c592" # MD5 for Gravatar
date: 2025-01-15T10:30:00Z
message: "Great article! Really helped me understand the concept."
reply_to: "" # Empty for top-level, ID for replies
| Pros | Cons |
|---|---|
| ✅ Free hosting (GitHub/GitLab) | ❌ Slow comment appearance (build time) |
| ✅ Comments in version control | ❌ Requires rebuild for each comment |
| ✅ Easy backup/export | ❌ Limited real-time features |
| ✅ Works offline | ❌ May expose email (or hash) |
| ✅ No external dependencies | ❌ Spam = polluted git history |
Use cloud functions to handle comment operations. Examples: AWS Lambda, Vercel Functions, Netlify Functions.
┌─────────────────────────────────────────────────────────────────────────┐
│ SERVERLESS ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ STATIC SITE (CDN) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Your Blog Post │ │ │
│ │ │ │ │ │
│ │ │ [Article Content] │ │ │
│ │ │ │ │ │
│ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ Comment Widget (JS) │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ fetch('/api/comments?page=my-article') │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ └────────────────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ API Calls │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ SERVERLESS FUNCTIONS │ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ GET /api/ │ │ POST /api/ │ │ DELETE /api/ │ │ │
│ │ │ comments │ │ comments │ │ comments/:id │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │ │
│ │ │ │ │ │ │
│ │ │ ┌────────────┴────────────┐ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ Spam Filter │ │ │ │
│ │ │ │ (Akismet/Custom) │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ └────────────┬────────────┘ │ │ │
│ │ │ │ │ │ │
│ │ └───────────────────┼───────────────────┘ │ │
│ │ │ │ │
│ └──────────────────────────────┼────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ DATABASE │ │
│ │ │ │
│ │ Options: │ │
│ │ • DynamoDB (AWS) │ │
│ │ • FaunaDB │ │
│ │ • PlanetScale (MySQL) │ │
│ │ • Supabase (PostgreSQL) │ │
│ │ • MongoDB Atlas │ │
│ │ • Cloudflare D1 (SQLite) │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
// api/comments/index.js (Vercel Serverless Function)
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_KEY
);
export default async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', 'https://yourblog.com');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
const { page_id } = req.query;
if (req.method === 'GET') {
// Fetch comments for a page
const { data, error } = await supabase
.from('comments')
.select('*')
.eq('page_id', page_id)
.eq('status', 'approved')
.order('created_at', { ascending: true });
if (error) {
return res.status(500).json({ error: error.message });
}
return res.status(200).json(data);
}
if (req.method === 'POST') {
const { author, email, content, parent_id } = req.body;
// Basic validation
if (!author || !content || !page_id) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Insert comment (pending moderation)
const { data, error } = await supabase
.from('comments')
.insert({
page_id,
parent_id: parent_id || null,
author,
email,
content,
status: 'pending', // Or 'approved' if auto-approve
ip_address: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
user_agent: req.headers['user-agent']
})
.select();
if (error) {
return res.status(500).json({ error: error.message });
}
return res.status(201).json(data[0]);
}
return res.status(405).json({ error: 'Method not allowed' });
}
| Pros | Cons |
|---|---|
| ✅ Pay-per-use pricing | ❌ Cold start latency |
| ✅ Auto-scaling | ❌ Vendor lock-in |
| ✅ Real-time comments | ❌ Database costs separate |
| ✅ Easy deployment | ❌ More moving parts |
| ✅ Good for variable traffic | ❌ Debugging complexity |
┌─────────────────────────────────────────────────────────────┐
│ SERVERLESS COST ESTIMATE (10K views/month) │
├─────────────────────────────────────────────────────────────┤
│ │
│ Vercel (Hobby Plan) │
│ ├── Functions: 100K invocations free $0.00 │
│ └── Bandwidth: 100GB free $0.00 │
│ │
│ Supabase (Free Tier) │
│ ├── Database: 500MB $0.00 │
│ ├── API calls: Unlimited $0.00 │
│ └── Bandwidth: 2GB $0.00 │
│ │
│ ───────────────────────────────────────────────── │
│ TOTAL (10K views): $0.00/month │
│ │
│ ───────────────────────────────────────────────── │
│ At 100K views: ~$5-10/month │
│ At 1M views: ~$25-50/month │
│ │
└─────────────────────────────────────────────────────────────┘
Run code at CDN edge locations for lowest latency. Examples: Cloudflare Workers, Deno Deploy.
┌─────────────────────────────────────────────────────────────────────────┐
│ EDGE ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ User in User in User in │
│ New York London Tokyo │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Edge │ │ Edge │ │ Edge │ │
│ │ Worker │ │ Worker │ │ Worker │ │
│ │ (NYC) │ │ (LHR) │ │ (TYO) │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ │ │ │ │
│ │ ┌───────────┴───────────┐ │ │
│ │ │ │ │ │
│ └────────► Distributed DB ◄───────┘ │
│ │ (Cloudflare D1 / │ │
│ │ Turso / etc.) │ │
│ │ │ │
│ └───────────────────────┘ │
│ │
│ Latency: ~20-50ms ~20-50ms ~20-50ms │
│ (vs 100-300ms for centralized) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
// worker.js - Cloudflare Worker with D1 Database
export default {
async fetch(request, env) {
const url = new URL(request.url);
// CORS
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
};
// GET /api/comments?page_id=xxx
if (request.method === 'GET' && url.pathname === '/api/comments') {
const pageId = url.searchParams.get('page_id');
const { results } = await env.DB.prepare(`
SELECT id, author, content, created_at, parent_id
FROM comments
WHERE page_id = ? AND status = 'approved'
ORDER BY created_at ASC
`).bind(pageId).all();
return new Response(JSON.stringify(results), { headers: corsHeaders });
}
// POST /api/comments
if (request.method === 'POST' && url.pathname === '/api/comments') {
const body = await request.json();
const { page_id, author, email, content, parent_id } = body;
// Spam check (simple example)
if (await isSpam(content, env)) {
return new Response(
JSON.stringify({ error: 'Comment flagged as spam' }),
{ status: 400, headers: corsHeaders }
);
}
const id = crypto.randomUUID();
await env.DB.prepare(`
INSERT INTO comments (id, page_id, parent_id, author, email, content, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', datetime('now'))
`).bind(id, page_id, parent_id || null, author, email, content).run();
return new Response(
JSON.stringify({ id, message: 'Comment submitted for moderation' }),
{ status: 201, headers: corsHeaders }
);
}
return new Response('Not Found', { status: 404 });
},
};
async function isSpam(content, env) {
// Simple spam detection - in production, use more sophisticated methods
const spamPatterns = [
/\b(viagra|casino|lottery|winner)\b/i,
/https?:\/\/[^\s]+\.(ru|cn|xyz)/i,
/(.)\1{10,}/, // Repeated characters
];
return spamPatterns.some(pattern => pattern.test(content));
}
-- schema.sql
CREATE TABLE comments (
id TEXT PRIMARY KEY,
page_id TEXT NOT NULL,
parent_id TEXT,
author TEXT NOT NULL,
email TEXT,
content TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX idx_page_id ON comments(page_id);
CREATE INDEX idx_status ON comments(status);
CREATE INDEX idx_parent_id ON comments(parent_id);
| Pros | Cons |
|---|---|
| ✅ Lowest latency globally | ❌ Limited compute time |
| ✅ No cold starts | ❌ Restricted runtime APIs |
| ✅ Very cheap at scale | ❌ Database consistency challenges |
| ✅ Built-in DDoS protection | ❌ Learning curve |
| ✅ Great free tier | ❌ Limited debugging tools |
Self-hosted server with database. Classic approach with most flexibility.
┌─────────────────────────────────────────────────────────────────────────┐
│ TRADITIONAL API ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ STATIC SITE │ │
│ │ (Netlify/Vercel/GitHub Pages) │ │
│ └────────────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ │ HTTPS API Calls │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ REVERSE PROXY │ │
│ │ (Nginx / Caddy / Traefik) │ │
│ │ │ │
│ │ • SSL Termination │ │
│ │ • Rate Limiting │ │
│ │ • Caching │ │
│ │ │ │
│ └────────────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ API SERVER │ │
│ │ (Python/Node/Go/Rust) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Routes │ │ Spam │ │ Auth │ │ │
│ │ │ Handler │──│ Filter │──│ Check │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────┐ │ │
│ │ │ ORM / │ │ │
│ │ │ Query │ │ │
│ │ └──────┬──────┘ │ │
│ │ │ │ │
│ └─────────┼─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ DATABASE │ │
│ │ (PostgreSQL / SQLite / MySQL) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ comments │ users │ pages │ spam_log │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ Deployment Options: │
│ • VPS (DigitalOcean, Linode, Hetzner): $4-20/month │
│ • Docker + fly.io: $0-5/month │
│ • Railway/Render: $0-7/month │
│ │
└─────────────────────────────────────────────────────────────────────────┘
# main.py - FastAPI Comment Server
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr
from sqlalchemy import create_engine, Column, String, DateTime, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime
import uuid
import hashlib
# Database setup
DATABASE_URL = "sqlite:///./comments.db"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
# Models
class Comment(Base):
__tablename__ = "comments"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
page_id = Column(String, nullable=False, index=True)
parent_id = Column(String, nullable=True)
author = Column(String, nullable=False)
email = Column(String, nullable=True)
email_hash = Column(String, nullable=True)
content = Column(Text, nullable=False)
status = Column(String, default="pending")
created_at = Column(DateTime, default=datetime.utcnow)
ip_address = Column(String)
user_agent = Column(String)
Base.metadata.create_all(engine)
# Pydantic schemas
class CommentCreate(BaseModel):
page_id: str
parent_id: str | None = None
author: str
email: EmailStr | None = None
content: str
class CommentResponse(BaseModel):
id: str
page_id: str
parent_id: str | None
author: str
email_hash: str | None
content: str
created_at: datetime
class Config:
from_attributes = True
# App
app = FastAPI(title="Comment API")
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourblog.com"],
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["*"],
)
def get_email_hash(email: str | None) -> str | None:
if not email:
return None
return hashlib.md5(email.lower().strip().encode()).hexdigest()
def check_spam(content: str, ip: str) -> bool:
"""Simple spam check - expand with Akismet, ML, etc."""
spam_keywords = ['viagra', 'casino', 'crypto', 'winner']
content_lower = content.lower()
return any(word in content_lower for word in spam_keywords)
@app.get("/api/comments", response_model=list[CommentResponse])
async def get_comments(page_id: str):
db = SessionLocal()
try:
comments = db.query(Comment).filter(
Comment.page_id == page_id,
Comment.status == "approved"
).order_by(Comment.created_at.asc()).all()
return comments
finally:
db.close()
@app.post("/api/comments", response_model=CommentResponse, status_code=201)
async def create_comment(comment: CommentCreate, request: Request):
# Get client info
ip = request.client.host
user_agent = request.headers.get("user-agent", "")
# Spam check
if check_spam(comment.content, ip):
raise HTTPException(status_code=400, detail="Comment flagged as spam")
db = SessionLocal()
try:
db_comment = Comment(
page_id=comment.page_id,
parent_id=comment.parent_id,
author=comment.author,
email=comment.email,
email_hash=get_email_hash(comment.email),
content=comment.content,
status="pending", # Change to "approved" for auto-approve
ip_address=ip,
user_agent=user_agent
)
db.add(db_comment)
db.commit()
db.refresh(db_comment)
return db_comment
finally:
db.close()
@app.get("/api/admin/pending")
async def get_pending_comments():
"""Admin endpoint - add authentication in production!"""
db = SessionLocal()
try:
comments = db.query(Comment).filter(
Comment.status == "pending"
).order_by(Comment.created_at.desc()).all()
return comments
finally:
db.close()
| Pros | Cons |
|---|---|
| ✅ Full control | ❌ Server maintenance required |
| ✅ Any language/framework | ❌ Fixed cost even at low traffic |
| ✅ No vendor lock-in | ❌ Need to handle scaling |
| ✅ Custom features unlimited | ❌ Security responsibility |
| ✅ Can be very cheap | ❌ More DevOps work |
Fetch comments at build time, update via webhooks.
┌─────────────────────────────────────────────────────────────────────────┐
│ HYBRID ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ BUILD TIME RUNTIME │
│ ────────────────────────────────────────────────────────────────────── │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ │ │ │ Comment │
│ │ Static │◄─────│ Comment │ API │
│ │ Site Gen │ │ API │◄──────────┐ │
│ │ │ │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │
│ │ │ Webhook │ │
│ ▼ │ (new comment) │ │
│ ┌─────────────┐ │ │ │
│ │ │◄───────────┘ │ │
│ │ Trigger │ │ │
│ │ Rebuild │ │ │
│ │ │ │ │
│ └──────┬──────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌─────────────┐ │ │
│ │ │ ┌─────────────┐ │ │
│ │ Deploy │─────►│ Users │───────────┘ │
│ │ to CDN │ │ │ │
│ │ │ │ • View static comments (fast) │
│ └─────────────┘ │ • Submit new comments (API) │
│ │ • See new comments on rebuild │
│ └─────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ KEY INSIGHT: Comments are "eventually static" │
│ • Initial load: Pre-rendered comments (lightning fast) │
│ • New comments: Via API (real-time submission) │
│ • Rebuild: Every X minutes or on webhook │
│ ───────────────────────────────────────────────────────────────────── │
│ │
└─────────────────────────────────────────────────────────────────────────┘
// Build script that fetches comments
// scripts/fetch-comments.js
const fs = require('fs');
const path = require('path');
async function fetchAllComments() {
const API_URL = process.env.COMMENT_API_URL;
const pages = getPageList(); // Your function to get all page IDs
for (const pageId of pages) {
const response = await fetch(`${API_URL}/api/comments?page_id=${pageId}`);
const comments = await response.json();
// Write to data file
const outputPath = path.join('data', 'comments', `${pageId}.json`);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(comments, null, 2));
}
}
fetchAllComments();
Use this decision tree:
START
│
▼
┌────────────────────────┐
│ What's your monthly │
│ traffic level? │
└───────────┬────────────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
< 1K views 1K - 50K views > 50K views
│ │ │
▼ ▼ ▼
Git-based Serverless Edge or
(Free) Functions Traditional
│ │ │
│ │ ▼
│ │ ┌──────────────┐
│ │ │ Need custom │
│ │ │ features? │
│ │ └──────┬───────┘
│ │ │
│ │ ┌──────┴──────┐
│ │ │ │
│ │ ▼ ▼
│ │ Yes No
│ │ │ │
│ │ ▼ ▼
│ │ Traditional Edge
│ │ API Server Functions
│ │ │
▼ ▼ ▼
Staticman Vercel + Cloudflare
Utterances Supabase Workers + D1
Giscus
| Architecture | Best For | Cost | Complexity |
|---|---|---|---|
| Git-based | Low traffic, tech audience | Free | Low |
| Serverless | Variable traffic, quick setup | $0-50/mo | Medium |
| Edge | Global audience, performance | $0-25/mo | Medium |
| Traditional | Custom needs, full control | $5-50/mo | High |
| Hybrid | Best of both worlds | Varies | High |
In the next chapter, we’ll dive deep into database design and data modeling for comments.
Navigation: