Learn from real-world implementations of self-hosted comment systems. This chapter examines different approaches that have been successfully deployed.
Site: A developer’s personal blog Traffic: 50K monthly visitors, ~500 comments/month Requirements:
┌─────────────────────────────────────────────────────────────┐
│ SERVERLESS BLOG COMMENTS │
├─────────────────────────────────────────────────────────────┤
│ │
│ Static Site Vercel Functions Supabase │
│ (Hugo on (API Routes) (PostgreSQL │
│ Cloudflare) + Auth) │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ │ GET │ │ Query │ │ │
│ │ Blog │────────▶│ /api/ │───────▶│ Database │ │
│ │ Posts │ │ comments │ │ │ │
│ │ │◀────────│ │◀───────│ │ │
│ └──────────┘ JSON └──────────────┘ Result └──────────┘ │
│ │ │
│ │ OAuth │
│ ▼ │
│ ┌──────────────┐ │
│ │ GitHub │ │
│ │ OAuth │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Supabase Schema:
-- Simple schema with GitHub auth
CREATE TABLE comments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
page_slug TEXT NOT NULL,
user_id UUID REFERENCES auth.users(id),
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS policies
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can read" ON comments
FOR SELECT USING (true);
CREATE POLICY "Authenticated users can insert" ON comments
FOR INSERT WITH CHECK (auth.role() = 'authenticated');
API Route (Vercel):
// api/comments/[slug].ts
import { createClient } from '@supabase/supabase-js';
export default async function handler(req, res) {
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY
);
const { slug } = req.query;
if (req.method === 'GET') {
const { data } = await supabase
.from('comments')
.select('*, user:auth.users(raw_user_meta_data)')
.eq('page_slug', slug)
.order('created_at');
return res.json(data);
}
// POST requires auth token
const token = req.headers.authorization?.split(' ')[1];
const { data: { user } } = await supabase.auth.getUser(token);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const { data } = await supabase
.from('comments')
.insert({
page_slug: slug,
user_id: user.id,
content: req.body.content
})
.select()
.single();
return res.status(201).json(data);
}
| Metric | Value |
|---|---|
| Monthly Cost | $0 |
| Average Response Time | 180ms |
| Uptime | 99.9% |
| Setup Time | 4 hours |
Site: Regional news publication Traffic: 500K monthly visitors, ~5,000 comments/month Requirements:
┌─────────────────────────────────────────────────────────────┐
│ MODERATED NEWS COMMENTS │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ Cloudflare │ │
│ │ (CDN + WAF) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Reader │ │ Comment │ │ Admin │ │
│ │ Widget │ │ API │ │ Panel │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │
│ ┌────────────────────┼────────────────────┤ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Akismet │ │ PostgreSQL│ │ Redis │ │
│ │ (Spam) │ │ (Primary) │ │ (Queue) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Moderation Queue with Priority:
class ModerationService:
PRIORITY_KEYWORDS = ['breaking', 'election', 'urgent']
async def get_queue(self, moderator_id: str) -> list:
# High priority: comments on breaking news
high_priority = await self.db.fetch("""
SELECT c.*, p.title as article_title
FROM comments c
JOIN pages p ON c.page_id = p.id
WHERE c.status = 'pending'
AND (
p.is_breaking = true
OR EXISTS (
SELECT 1 FROM unnest(:keywords) k
WHERE p.title ILIKE '%' || k || '%'
)
)
ORDER BY c.created_at ASC
LIMIT 50
""", keywords=self.PRIORITY_KEYWORDS)
# Normal priority
normal = await self.db.fetch("""
SELECT c.*, p.title as article_title
FROM comments c
JOIN pages p ON c.page_id = p.id
WHERE c.status = 'pending'
AND c.id NOT IN :high_priority_ids
ORDER BY c.created_at ASC
LIMIT 50
""", high_priority_ids=[c['id'] for c in high_priority])
return {
'high_priority': high_priority,
'normal': normal
}
Spam Pre-filtering:
async def process_new_comment(comment: CommentInput) -> Comment:
# Check Akismet first
is_spam = await akismet.check_comment(
comment.content,
comment.author_name,
comment.author_email,
request.client.host
)
if is_spam:
# Auto-reject obvious spam
return await create_comment(comment, status='spam')
# Check for banned words
if contains_banned_words(comment.content):
return await create_comment(comment, status='pending', flag='language')
# Queue for moderation
return await create_comment(comment, status='pending')
| Metric | Value |
|---|---|
| Monthly Cost | $75 |
| Spam Caught | 98% |
| Avg Moderation Time | 12 minutes |
| Comments Approved | 82% |
Site: Photography community forum Migration from: Disqus (cost: $99/month) Traffic: 100K monthly visitors, ~2,000 comments/month Requirements:
Phase 1: Data Export from Disqus
import xml.etree.ElementTree as ET
def parse_disqus_export(xml_file: str) -> list:
tree = ET.parse(xml_file)
root = tree.getroot()
comments = []
threads = {}
# First pass: collect threads (pages)
for thread in root.findall('.//thread'):
thread_id = thread.get('dsq:id')
threads[thread_id] = {
'url': thread.find('link').text,
'title': thread.find('title').text
}
# Second pass: collect comments
for post in root.findall('.//post'):
if post.find('isDeleted').text == 'true':
continue
thread_id = post.find('thread').get('dsq:id')
parent = post.find('parent')
comments.append({
'disqus_id': post.get('dsq:id'),
'thread_url': threads[thread_id]['url'],
'parent_disqus_id': parent.get('dsq:id') if parent is not None else None,
'author_name': post.find('author/name').text,
'author_email': post.find('author/email').text,
'content': post.find('message').text,
'created_at': post.find('createdAt').text
})
return comments
Phase 2: Import with ID Mapping
async def import_comments(comments: list):
id_mapping = {} # disqus_id -> new_id
# Sort by created_at to maintain order
comments.sort(key=lambda c: c['created_at'])
for comment in comments:
# Map parent ID
parent_id = None
if comment['parent_disqus_id']:
parent_id = id_mapping.get(comment['parent_disqus_id'])
# Create in new system
new_comment = await db.execute("""
INSERT INTO comments (page_id, parent_id, author_name, author_email, content, created_at, status)
VALUES (:page_id, :parent_id, :author_name, :author_email, :content, :created_at, 'approved')
RETURNING id
""", {
'page_id': url_to_page_id(comment['thread_url']),
'parent_id': parent_id,
'author_name': comment['author_name'],
'author_email': comment['author_email'],
'content': comment['content'],
'created_at': comment['created_at']
})
id_mapping[comment['disqus_id']] = new_comment['id']
return len(id_mapping)
Phase 3: Parallel Running
<!-- Show both during transition -->
<div id="new-comments">
<!-- New comment system -->
</div>
<details>
<summary>View older comments (from Disqus)</summary>
<div id="disqus_thread"></div>
</details>
| Metric | Before (Disqus) | After |
|---|---|---|
| Monthly Cost | $99 | $35 |
| Page Load Impact | +800ms | +200ms |
| User Satisfaction | 3.2/5 | 4.5/5 |
| Data Ownership | No | Yes |
Site: Personal portfolio/blog Traffic: 5K monthly visitors, ~20 comments/month Requirements:
Using Staticman pattern with GitHub Actions:
┌─────────────────────────────────────────────────────────────┐
│ GIT-BASED COMMENTS │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ User │───▶│ Netlify │───▶│ GitHub │ │
│ │ Browser │ │ Function │ │ API │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ │ Create File │
│ ▼ │
│ ┌──────────┐ │
│ │ Repo │ │
│ │ /data/ │ │
│ │ comments │ │
│ └──────────┘ │
│ │ │
│ │ Trigger Build │
│ ▼ │
│ ┌──────────┐ │
│ ┌──────────┐ Deploy │ Hugo │ │
│ │ CDN │◀──────────────────│ Build │ │
│ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Implementation:
// netlify/functions/comment.js
const { Octokit } = require('@octokit/rest');
exports.handler = async (event) => {
if (event.httpMethod !== 'POST') {
return { statusCode: 405, body: 'Method not allowed' };
}
const { slug, author, content } = JSON.parse(event.body);
// Basic validation
if (!slug || !author || !content) {
return { statusCode: 400, body: 'Missing fields' };
}
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
// Create comment file
const timestamp = Date.now();
const filename = `data/comments/${slug}/${timestamp}.json`;
const commentData = {
author,
content,
date: new Date().toISOString(),
approved: false // Requires PR merge
};
await octokit.repos.createOrUpdateFileContents({
owner: 'username',
repo: 'blog',
path: filename,
message: `New comment on ${slug}`,
content: Buffer.from(JSON.stringify(commentData, null, 2)).toString('base64'),
branch: 'comments' // Separate branch for review
});
// Create PR for moderation
await octokit.pulls.create({
owner: 'username',
repo: 'blog',
title: `Comment: ${author} on ${slug}`,
head: 'comments',
base: 'main',
body: `New comment:\n\n> ${content}\n\nMerge to approve.`
});
return {
statusCode: 201,
body: JSON.stringify({ message: 'Comment submitted for review' })
};
};
Hugo Template:
{{ $comments := slice }}
{{ $path := printf "data/comments/%s" .File.BaseFileName }}
{{ range $file := readDir $path }}
{{ $comment := getJSON (printf "%s/%s" $path $file.Name) }}
{{ if $comment.approved }}
{{ $comments = $comments | append $comment }}
{{ end }}
{{ end }}
<section class="comments">
<h2>Comments ({{ len $comments }})</h2>
{{ range sort $comments "date" "desc" }}
<article class="comment">
<strong>{{ .author }}</strong>
<time>{{ .date | dateFormat "Jan 2, 2006" }}</time>
<p>{{ .content | markdownify }}</p>
</article>
{{ end }}
</section>
| Metric | Value |
|---|---|
| Monthly Cost | $0 |
| Comment Display | Static (no JS needed) |
| Moderation | GitHub PR review |
| Build Time | ~30 seconds |
| Scenario | Recommended Stack | Cost |
|---|---|---|
| Dev blog, low traffic | Git-based (Staticman) | $0 |
| Personal site, medium traffic | Serverless (Vercel + Supabase) | $0 |
| Business blog | Fly.io + PostgreSQL | $20-50 |
| High-traffic news | Dedicated + Akismet | $50-150 |
| Community forum | Full stack + Redis | $50-100 |
Navigation: