A great comment system needs an intuitive, responsive frontend. This chapter covers building the user interface that integrates with your static site.
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>
Encapsulated, reusable component:
<comment-widget page-id="/blog/my-post" theme="dark"></comment-widget>
<script type="module" src="/comments/widget.js"></script>
Direct integration for React, Vue, etc:
import { CommentSection } from '@yourcomments/react';
export default function BlogPost({ slug }) {
return <CommentSection pageId={slug} />;
}
// 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);
});
// 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';
}
}
/* 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;
}
/* 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;
}
}
// 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>
);
}
// 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>
);
}
<!-- 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>
<!-- 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 }}
<!-- _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 %}
---
// 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>
Ensure your comment widget is accessible:
<article>, <header>, <time>)// 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);
}
| 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: