Chapter 13: Case Studies and Real Implementations

Learn from real-world implementations of self-hosted comment systems. This chapter examines different approaches that have been successfully deployed.

Case Study 1: Technical Blog with Serverless Stack

Background

Site: A developer’s personal blog Traffic: 50K monthly visitors, ~500 comments/month Requirements:

Architecture

┌─────────────────────────────────────────────────────────────┐
│                    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      │                     │
│                       └──────────────┘                     │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Implementation Highlights

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);
}

Results

Metric Value
Monthly Cost $0
Average Response Time 180ms
Uptime 99.9%
Setup Time 4 hours

Lessons Learned

  1. GitHub auth filters spam naturally - Bot accounts rarely have GitHub
  2. Supabase RLS simplifies auth - No custom middleware needed
  3. Edge caching crucial - Added Cloudflare caching for GET requests

Case Study 2: News Site with Heavy Moderation

Background

Site: Regional news publication Traffic: 500K monthly visitors, ~5,000 comments/month Requirements:

Architecture

┌─────────────────────────────────────────────────────────────┐
│                    MODERATED NEWS COMMENTS                   │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│                    ┌─────────────────┐                      │
│                    │   Cloudflare    │                      │
│                    │   (CDN + WAF)   │                      │
│                    └────────┬────────┘                      │
│                             │                                │
│       ┌────────────────────┼────────────────────┐           │
│       │                    │                    │           │
│       ▼                    ▼                    ▼           │
│  ┌──────────┐       ┌──────────┐        ┌──────────┐       │
│  │ Reader   │       │ Comment  │        │  Admin   │       │
│  │ Widget   │       │   API    │        │  Panel   │       │
│  └──────────┘       └──────────┘        └──────────┘       │
│                            │                    │           │
│       ┌────────────────────┼────────────────────┤           │
│       │                    │                    │           │
│       ▼                    ▼                    ▼           │
│  ┌──────────┐       ┌──────────┐        ┌──────────┐       │
│  │ Akismet  │       │ PostgreSQL│       │  Redis   │       │
│  │  (Spam)  │       │ (Primary) │       │ (Queue)  │       │
│  └──────────┘       └──────────┘        └──────────┘       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Key Features

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')

Results

Metric Value
Monthly Cost $75
Spam Caught 98%
Avg Moderation Time 12 minutes
Comments Approved 82%

Lessons Learned

  1. Akismet pays for itself - Saves hours of moderator time
  2. Priority queue essential - Breaking news needs fast moderation
  3. Moderator tools matter - Keyboard shortcuts, bulk actions save time

Case Study 3: Community Forum Migration

Background

Site: Photography community forum Migration from: Disqus (cost: $99/month) Traffic: 100K monthly visitors, ~2,000 comments/month Requirements:

Migration Strategy

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>

Final Stack

Results

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

Lessons Learned

  1. Migration is doable - Most platforms have export options
  2. Run parallel first - Catch issues before full cutover
  3. Users prefer faster - Performance improvement was most noticed

Case Study 4: Minimal Static Site

Background

Site: Personal portfolio/blog Traffic: 5K monthly visitors, ~20 comments/month Requirements:

Architecture: Git-Based Comments

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>

Results

Metric Value
Monthly Cost $0
Comment Display Static (no JS needed)
Moderation GitHub PR review
Build Time ~30 seconds

Lessons Learned

  1. Git-based works for low volume - Not suitable for high traffic
  2. PR review is intuitive - Familiar workflow for developers
  3. No JavaScript required - Comments rendered at build time

Summary: Choosing Your Approach

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: