Chapter 5: Frontend Integration

A great comment system needs an intuitive, responsive frontend. This chapter covers building the user interface that integrates with your static site.

Integration Approaches

Embed Script

The simplest approach - add one script tag:

<div id="comments" data-page-id="/blog/my-post"></div>
<script src="https://comments.yourdomain.com/embed.js" async></script>

Web Component

Encapsulated, reusable component:

<comment-widget page-id="/blog/my-post" theme="dark"></comment-widget>
<script type="module" src="/comments/widget.js"></script>

Framework Integration

Direct integration for React, Vue, etc:

import { CommentSection } from '@yourcomments/react';

export default function BlogPost({ slug }) {
    return <CommentSection pageId={slug} />;
}

Vanilla JavaScript Widget

Core Widget Structure

// widget.js
class CommentWidget {
    constructor(container, options) {
        this.container = container;
        this.options = {
            apiUrl: 'https://api.yourdomain.com',
            pageId: container.dataset.pageId || window.location.pathname,
            theme: 'light',
            ...options
        };
        this.comments = [];
        this.init();
    }
    
    async init() {
        this.render();
        await this.loadComments();
        this.attachEventListeners();
    }
    
    render() {
        this.container.innerHTML = `
            <div class="comment-widget" data-theme="${this.options.theme}">
                <div class="comment-form-container"></div>
                <div class="comment-list"></div>
                <div class="comment-loading">Loading comments...</div>
            </div>
        `;
    }
    
    async loadComments() {
        const response = await fetch(
            `${this.options.apiUrl}/comments?page_id=${encodeURIComponent(this.options.pageId)}`
        );
        const { data } = await response.json();
        this.comments = data;
        this.renderComments();
    }
    
    renderComments() {
        const list = this.container.querySelector('.comment-list');
        const loading = this.container.querySelector('.comment-loading');
        loading.style.display = 'none';
        
        if (this.comments.length === 0) {
            list.innerHTML = '<p class="no-comments">No comments yet. Be the first!</p>';
            return;
        }
        
        list.innerHTML = this.comments.map(c => this.renderComment(c)).join('');
    }
    
    renderComment(comment) {
        const date = new Date(comment.created_at).toLocaleDateString();
        return `
            <article class="comment" data-id="${comment.id}">
                <header class="comment-header">
                    <img class="comment-avatar" 
                         src="${this.getGravatar(comment.author_email)}" 
                         alt="${comment.author_name}">
                    <span class="comment-author">${this.escapeHtml(comment.author_name)}</span>
                    <time class="comment-date">${date}</time>
                </header>
                <div class="comment-content">${this.escapeHtml(comment.content)}</div>
                <footer class="comment-actions">
                    <button class="reply-btn" data-id="${comment.id}">Reply</button>
                </footer>
                ${comment.replies ? this.renderReplies(comment.replies) : ''}
            </article>
        `;
    }
    
    escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }
    
    getGravatar(email) {
        // Use a default avatar if no email
        if (!email) return 'https://www.gravatar.com/avatar/?d=mp';
        const hash = this.md5(email.toLowerCase().trim());
        return `https://www.gravatar.com/avatar/${hash}?d=mp`;
    }
}

// Initialize
document.querySelectorAll('[data-comments]').forEach(el => {
    new CommentWidget(el);
});

Comment Form

// form.js
renderForm(parentId = null) {
    return `
        <form class="comment-form" data-parent="${parentId || ''}">
            <div class="form-group">
                <label for="author">Name *</label>
                <input type="text" id="author" name="author" required 
                       minlength="2" maxlength="100">
            </div>
            <div class="form-group">
                <label for="email">Email</label>
                <input type="email" id="email" name="email" 
                       placeholder="Optional, for Gravatar">
            </div>
            <div class="form-group">
                <label for="content">Comment *</label>
                <textarea id="content" name="content" required 
                          minlength="1" maxlength="5000" rows="4"></textarea>
            </div>
            <div class="form-actions">
                <button type="submit" class="submit-btn">Post Comment</button>
                ${parentId ? '<button type="button" class="cancel-btn">Cancel</button>' : ''}
            </div>
            <p class="form-note">
                Your comment will appear after moderation.
            </p>
        </form>
    `;
}

async handleSubmit(event) {
    event.preventDefault();
    const form = event.target;
    const submitBtn = form.querySelector('.submit-btn');
    
    submitBtn.disabled = true;
    submitBtn.textContent = 'Posting...';
    
    try {
        const response = await fetch(`${this.options.apiUrl}/comments`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                page_id: this.options.pageId,
                parent_id: form.dataset.parent || null,
                author_name: form.author.value,
                author_email: form.email.value,
                content: form.content.value
            })
        });
        
        if (!response.ok) throw new Error('Failed to post comment');
        
        form.reset();
        this.showMessage('Comment submitted! It will appear after approval.');
    } catch (error) {
        this.showMessage('Failed to post comment. Please try again.', 'error');
    } finally {
        submitBtn.disabled = false;
        submitBtn.textContent = 'Post Comment';
    }
}

CSS Styling

Base Styles

/* comments.css */
.comment-widget {
    --primary-color: #3b82f6;
    --text-color: #1f2937;
    --bg-color: #ffffff;
    --border-color: #e5e7eb;
    --muted-color: #6b7280;
    
    font-family: system-ui, -apple-system, sans-serif;
    color: var(--text-color);
    max-width: 720px;
    margin: 2rem auto;
}

.comment-widget[data-theme="dark"] {
    --text-color: #f3f4f6;
    --bg-color: #1f2937;
    --border-color: #374151;
    --muted-color: #9ca3af;
}

/* Comment List */
.comment-list {
    margin-top: 2rem;
}

.comment {
    padding: 1rem;
    border: 1px solid var(--border-color);
    border-radius: 8px;
    margin-bottom: 1rem;
    background: var(--bg-color);
}

.comment-header {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin-bottom: 0.75rem;
}

.comment-avatar {
    width: 40px;
    height: 40px;
    border-radius: 50%;
}

.comment-author {
    font-weight: 600;
}

.comment-date {
    color: var(--muted-color);
    font-size: 0.875rem;
    margin-left: auto;
}

.comment-content {
    line-height: 1.6;
    white-space: pre-wrap;
}

/* Replies */
.comment-replies {
    margin-left: 2rem;
    margin-top: 1rem;
    padding-left: 1rem;
    border-left: 2px solid var(--border-color);
}

/* Form */
.comment-form {
    background: var(--bg-color);
    padding: 1.5rem;
    border-radius: 8px;
    border: 1px solid var(--border-color);
}

.form-group {
    margin-bottom: 1rem;
}

.form-group label {
    display: block;
    margin-bottom: 0.25rem;
    font-weight: 500;
}

.form-group input,
.form-group textarea {
    width: 100%;
    padding: 0.5rem 0.75rem;
    border: 1px solid var(--border-color);
    border-radius: 4px;
    font-size: 1rem;
    background: var(--bg-color);
    color: var(--text-color);
}

.submit-btn {
    background: var(--primary-color);
    color: white;
    padding: 0.625rem 1.25rem;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
}

.submit-btn:hover {
    opacity: 0.9;
}

.submit-btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

Responsive Design

/* Mobile Optimizations */
@media (max-width: 640px) {
    .comment-widget {
        margin: 1rem;
    }
    
    .comment-replies {
        margin-left: 1rem;
    }
    
    .comment-header {
        flex-wrap: wrap;
    }
    
    .comment-date {
        margin-left: 0;
        width: 100%;
        margin-top: 0.25rem;
    }
}

React Component

Comment Component

// CommentSection.jsx
import { useState, useEffect } from 'react';

export function CommentSection({ pageId, apiUrl = '/api' }) {
    const [comments, setComments] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        fetchComments();
    }, [pageId]);
    
    async function fetchComments() {
        try {
            const res = await fetch(`${apiUrl}/comments?page_id=${pageId}`);
            const { data } = await res.json();
            setComments(data);
        } catch (err) {
            setError('Failed to load comments');
        } finally {
            setLoading(false);
        }
    }
    
    if (loading) return <div>Loading comments...</div>;
    if (error) return <div>{error}</div>;
    
    return (
        <div className="comment-section">
            <h3>{comments.length} Comments</h3>
            <CommentForm pageId={pageId} onSubmit={fetchComments} />
            <CommentList comments={comments} onReply={fetchComments} />
        </div>
    );
}

function CommentList({ comments, onReply }) {
    if (comments.length === 0) {
        return <p>No comments yet. Be the first to comment!</p>;
    }
    
    return (
        <div className="comment-list">
            {comments.map(comment => (
                <Comment key={comment.id} comment={comment} onReply={onReply} />
            ))}
        </div>
    );
}

function Comment({ comment, onReply, depth = 0 }) {
    const [showReplyForm, setShowReplyForm] = useState(false);
    
    return (
        <article className="comment" style=>
            <header>
                <strong>{comment.author_name}</strong>
                <time>{new Date(comment.created_at).toLocaleDateString()}</time>
            </header>
            <p>{comment.content}</p>
            <button onClick={() => setShowReplyForm(!showReplyForm)}>
                Reply
            </button>
            
            {showReplyForm && (
                <CommentForm 
                    pageId={comment.page_id} 
                    parentId={comment.id}
                    onSubmit={() => { setShowReplyForm(false); onReply(); }}
                    onCancel={() => setShowReplyForm(false)}
                />
            )}
            
            {comment.replies?.map(reply => (
                <Comment key={reply.id} comment={reply} onReply={onReply} depth={depth + 1} />
            ))}
        </article>
    );
}

Comment Form Component

// CommentForm.jsx
import { useState } from 'react';

export function CommentForm({ pageId, parentId = null, onSubmit, onCancel }) {
    const [formData, setFormData] = useState({
        author_name: '',
        author_email: '',
        content: ''
    });
    const [submitting, setSubmitting] = useState(false);
    const [message, setMessage] = useState(null);
    
    async function handleSubmit(e) {
        e.preventDefault();
        setSubmitting(true);
        
        try {
            const res = await fetch('/api/comments', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    ...formData,
                    page_id: pageId,
                    parent_id: parentId
                })
            });
            
            if (!res.ok) throw new Error('Failed');
            
            setMessage('Comment submitted for review!');
            setFormData({ author_name: '', author_email: '', content: '' });
            onSubmit?.();
        } catch (err) {
            setMessage('Failed to submit. Please try again.');
        } finally {
            setSubmitting(false);
        }
    }
    
    return (
        <form onSubmit={handleSubmit} className="comment-form">
            <input
                type="text"
                placeholder="Your name *"
                value={formData.author_name}
                onChange={e => setFormData({...formData, author_name: e.target.value})}
                required
            />
            <input
                type="email"
                placeholder="Email (optional)"
                value={formData.author_email}
                onChange={e => setFormData({...formData, author_email: e.target.value})}
            />
            <textarea
                placeholder="Your comment *"
                value={formData.content}
                onChange={e => setFormData({...formData, content: e.target.value})}
                required
                rows={4}
            />
            <div className="form-actions">
                <button type="submit" disabled={submitting}>
                    {submitting ? 'Posting...' : 'Post Comment'}
                </button>
                {onCancel && (
                    <button type="button" onClick={onCancel}>Cancel</button>
                )}
            </div>
            {message && <p className="message">{message}</p>}
        </form>
    );
}

Vue Component

<!-- CommentSection.vue -->
<template>
    <div class="comment-section">
        <h3>{{ comments.length }} Comments</h3>
        
        <form @submit.prevent="submitComment" class="comment-form">
            <input v-model="form.author_name" placeholder="Name *" required />
            <input v-model="form.author_email" type="email" placeholder="Email" />
            <textarea v-model="form.content" placeholder="Comment *" required rows="4" />
            <button type="submit" :disabled="submitting">
                {{ submitting ? 'Posting...' : 'Post Comment' }}
            </button>
        </form>
        
        <div v-if="loading">Loading...</div>
        <div v-else-if="comments.length === 0">No comments yet.</div>
        <div v-else class="comment-list">
            <article v-for="comment in comments" :key="comment.id" class="comment">
                <header>
                    <strong>{{ comment.author_name }}</strong>
                    <time>{{ formatDate(comment.created_at) }}</time>
                </header>
                <p>{{ comment.content }}</p>
            </article>
        </div>
    </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';

const props = defineProps(['pageId']);
const comments = ref([]);
const loading = ref(true);
const submitting = ref(false);
const form = ref({ author_name: '', author_email: '', content: '' });

onMounted(fetchComments);

async function fetchComments() {
    const res = await fetch(`/api/comments?page_id=${props.pageId}`);
    const { data } = await res.json();
    comments.value = data;
    loading.value = false;
}

async function submitComment() {
    submitting.value = true;
    await fetch('/api/comments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...form.value, page_id: props.pageId })
    });
    form.value = { author_name: '', author_email: '', content: '' };
    submitting.value = false;
    fetchComments();
}

function formatDate(date) {
    return new Date(date).toLocaleDateString();
}
</script>

Static Site Generator Integration

Hugo Partial

<!-- layouts/partials/comments.html -->
{{ if .Site.Params.comments.enabled }}
<section id="comments" class="comments-section">
    <h2>Comments</h2>
    <div id="comment-widget" 
         data-page-id="{{ .RelPermalink }}"
         data-api="{{ .Site.Params.comments.api }}">
    </div>
    <script src="{{ .Site.Params.comments.script }}" async></script>
</section>
{{ end }}

Jekyll Include

<!-- _includes/comments.html -->
{% if site.comments.enabled %}
<div id="comments"
     data-page-id="{{ page.url }}"
     data-api="{{ site.comments.api_url }}">
</div>
<script src="{{ site.comments.script_url }}"></script>
{% endif %}

Astro Component

---
// src/components/Comments.astro
const { pageId } = Astro.props;
const apiUrl = import.meta.env.PUBLIC_COMMENTS_API;
---

<div id="comments" data-page-id={pageId} data-api={apiUrl}></div>
<script src="/scripts/comments.js"></script>

Accessibility Considerations

Ensure your comment widget is accessible:

// Announce new comment to screen readers
function announceComment(message) {
    const announcement = document.createElement('div');
    announcement.setAttribute('role', 'status');
    announcement.setAttribute('aria-live', 'polite');
    announcement.textContent = message;
    document.body.appendChild(announcement);
    setTimeout(() => announcement.remove(), 1000);
}

Chapter Summary

Integration Best For Complexity
Embed Script Any site Low
Web Component Modern browsers Medium
React/Vue SPA apps Medium
Hugo/Jekyll Static generators Low

In the next chapter, we’ll explore authentication strategies for comment systems.


Navigation: