Introduction

Blogs built with Static Site Generators (SSG) are incredibly fast and secure. However, adding dynamic features like comments requires careful consideration. This blog uses Giscus, a GitHub Discussions-based comment system, but there was one inconvenience.

Comments only appear at the bottom of each post, making it difficult to see at a glance which articles have active discussions.

So I built three features:

  1. Display comment counts in post lists - See which articles have ongoing conversations
  2. Show recent comments on the explore page - View recent blog activity at a glance
  3. Create an all comments page - Browse all comments in one place

This article shares the process of bringing dynamic comment information to a static site using GitHub GraphQL API while maintaining optimal performance.

Implemented Features

Updated comment viewing interface - including post lists, recent comments, and all comments page
Updated comment viewing interface - including post lists, recent comments, and all comments page
Updated comment UI on codeslog

1. Comment Counts in Post Lists

Display comment counts for each post in lists (/posts/, /archives/).

Features:

  • Only shows when comments ≥ 1
  • Completely hidden when no comments (no trailing separator)
  • Screen reader accessible with proper labels
  • 5-minute caching to minimize API calls
html
<!-- Output example -->
January 12, 2026 · 5 min · Isaac · 3 comments

2. Recent Comments on Explore Page

Display recent comments as cards on the /explore/ page.

Displayed Information:

  • Author profile picture and name
  • Relative time (e.g., “3 minutes ago”, “2 hours ago”)
  • Comment content preview (max 150 characters)
  • “View post” link (scrolls directly to the comment!)

3. All Comments Page

Created a dedicated page at /explore/comments/ to view all blog comments.

Features:

  • Fetches comments from up to 100 discussions
  • Sorted by newest first
  • Same design as recent comments
  • 5-minute cache + stale-while-revalidate pattern for fast display

Tech Stack

Giscus: GitHub Discussions-Based Comment System

Giscus is an open-source comment system that uses GitHub Discussions as the backend.

Advantages:

  • Convenient GitHub account login
  • Markdown support
  • Reactions and nested replies supported
  • Free with no ads
  • Data stored on GitHub (direct ownership)

Limitations:

  • Comment information is only accessible within the iframe
  • Requires separate implementation to display comment counts in post lists

GitHub GraphQL API

Concept illustration showing GraphQL query flow - developer laptop sending a single GraphQL query to GitHub server and receiving structured data
Concept illustration showing GraphQL query flow - developer laptop sending a single GraphQL query to GitHub server and receiving structured data
Created by: NanoBanana

GitHub provides a GraphQL API in addition to RESTful API. GraphQL is much more efficient for fetching comment information.

Query used:

graphql
query($owner: String!, $repo: String!, $categoryId: ID!) {
  repository(owner: $owner, name: $repo) {
    discussions(first: 50, categoryId: $categoryId, orderBy: {field: UPDATED_AT, direction: DESC}) {
      nodes {
        title
        url
        comments(last: 10) {
          totalCount
          nodes {
            author {
              login
              url
              avatarUrl
            }
            body
            bodyText
            createdAt
            url
          }
        }
      }
    }
  }
}

Why GraphQL:

  • Request only the data you need (no unnecessary data transfer)
  • Fetch multiple resources in a single request
  • Less network overhead compared to RESTful API

Creating GitHub Personal Access Token

GitHub API can be used without authentication, but it’s limited to 60 requests per hour. Using a Personal Access Token (PAT) increases this to 5000 requests per hour.

1. Generate Token on GitHub

GitHub token generation page
GitHub token generation page
Screenshot: GitHub
  1. Log in to GitHub and go to Settings > Developer settings > Personal access tokens > Fine-grained tokens

  2. Click “Generate new token”

  3. Configure as follows:

    • Token name: Blog Comment API (any name you prefer)
    • Expiration: 90 days or No expiration (90 days recommended)
    • Repository access: Public Repositories (read-only)
    • Permissions:
      • Discussions: Read-only (required)
      • Rest set to No access
  4. Click “Generate token”

  5. Copy the generated token immediately (cannot be viewed again)

The generated token is a long string starting with github_pat_ or ghp_.

2. Token Security Management

Security Measures (Required):

  • Never commit tokens to Git
  • Be especially careful with public repositories
  • Always add .env file to .gitignore

Setting Up Local Development Environment

1. Create .env File

Create a .env file in the project root:

bash
# .env
GITHUB_TOKEN=github_pat_paste_your_actual_token_here

2. Configure Hugo Security Policy

Hugo restricts environment variable access by default. Add this to config.yaml:

yaml
# config.yaml
security:
  funcs:
    getenv:
      - ^HUGO_
      - ^CI$
      - ^GITHUB_TOKEN$

3. Use Token in Hugo Template

Create layouts/partials/github-token.html:

html
{{- if getenv "GITHUB_TOKEN" -}}
<meta name="github-token" content="{{ getenv "GITHUB_TOKEN" }}">
{{- else if site.Params.githubToken -}}
<meta name="github-token" content="{{ site.Params.githubToken }}">
{{- end -}}

Include it in layouts/partials/extend_head.html:

html
<!-- layouts/partials/extend_head.html -->
{{- partial "github-token.html" . -}}

4. Add .env to .gitignore

bash
# .gitignore
# Environment variables (contains secrets)
.env
.env.local
.env.*.local

5. Test Locally

bash
# Run from directory containing .env file
hugo server

# Or pass environment variable directly
GITHUB_TOKEN=your_token_here hugo server

Configuring Cloudflare Pages Environment Variables

Cloudflare Pages Settings Page
Cloudflare Pages Settings Page
Cloudflare environment variables settings page

Once local testing is successful, configure the production environment.

1. Access Cloudflare Pages Dashboard

  1. Log in to Cloudflare Dashboard
  2. Navigate to Workers & Pages
  3. Select your site (project)
  4. Click Settings tab
  5. Scroll to Environment variables section

2. Add Environment Variable

  1. Click “Add variable” button

  2. Enter the following:

    • Variable name: GITHUB_TOKEN
    • Value: (paste the token you copied earlier)
    • Environment: Select both Production and Preview
  3. Click “Save”

3. Redeploy

Environment variables don’t apply to existing deployments. Redeploy using one of these methods:

Method 1: Git Push

bash
git commit --allow-empty -m "Trigger rebuild for environment variables"
git push

Method 2: Cloudflare Dashboard

  • In the Deployments tab, click “Retry deployment” next to the latest deployment

4. Verify Deployment

After deployment completes, check on your live site:

  • Are comment counts displayed in post lists?
  • Do recent comments appear on the explore page?
  • Does the all comments page (/explore/comments/) work?

You can also check if GitHub API calls succeed in the browser’s Network tab.

Detailed Code Implementation

Calling GitHub API with JavaScript

Fetch comment counts in static/js/comment-counts.js:

javascript
const GITHUB_TOKEN = document.querySelector('meta[name="github-token"]')?.content || '';

async function fetchCommentCounts() {
  const query = `
    query($owner: String!, $repo: String!, $categoryId: ID!) {
      repository(owner: $owner, name: $repo) {
        discussions(first: 50, categoryId: $categoryId) {
          nodes {
            title
            comments {
              totalCount
            }
          }
        }
      }
    }
  `;

  const headers = {
    'Content-Type': 'application/json',
  };

  if (GITHUB_TOKEN) {
    headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
  }

  const response = await fetch('https://api.github.com/graphql', {
    method: 'POST',
    headers: headers,
    body: JSON.stringify({
      query,
      variables: {
        owner: 'codeslog',
        repo: 'codeslog-comments',
        categoryId: 'DIC_kwDOP13p-c4Cv1Rv'
      }
    })
  });

  const result = await response.json();

  // Map comment counts by giscus_term
  const counts = {};
  result.data.repository.discussions.nodes.forEach(discussion => {
    counts[discussion.title] = discussion.comments.totalCount;
  });

  return counts;
}

Caching with localStorage

Cache for 5 minutes to reduce API calls:

javascript
const CACHE_KEY = 'codeslog_comment_counts';
const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes

function getCachedData() {
  try {
    const cached = localStorage.getItem(CACHE_KEY);
    if (!cached) return null;

    const data = JSON.parse(cached);
    const now = Date.now();

    if (now - data.timestamp < CACHE_DURATION) {
      return data.counts;
    }
  } catch (e) {
    console.warn('Failed to read cache:', e);
  }
  return null;
}

function setCachedData(counts) {
  try {
    localStorage.setItem(CACHE_KEY, JSON.stringify({
      timestamp: Date.now(),
      counts: counts
    }));
  } catch (e) {
    console.warn('Failed to save cache:', e);
  }
}

Stale-While-Revalidate Pattern

Display cached data immediately and update in the background:

javascript
async function init() {
  // Show cached data first if available
  const cached = getCachedData();
  if (cached) {
    updateCommentCounts(cached);

    // Fetch new data in background
    fetchCommentCounts().then(counts => {
      setCachedData(counts);
      updateCommentCounts(counts);
    }).catch(error => {
      console.error('Background update failed:', error);
    });

    return;
  }

  // Fetch new data if no cache
  try {
    const counts = await fetchCommentCounts();
    setCachedData(counts);
    updateCommentCounts(counts);
  } catch (error) {
    console.error('Failed to fetch:', error);
  }
}

Benefits of this pattern:

  • Instant display: Cached data shows immediately for fast response
  • Always fresh: Background updates keep data current
  • Error resilient: Cached data remains even if background request fails

Modifying Hugo Template

Add comment count wrapper in layouts/partials/post_meta.html:

html
{{- with ($scratch.Get "meta") }}
{{- delimit . "&nbsp;·&nbsp;" | safeHTML -}}
{{- end -}}

{{- if (.Param "giscus_term") -}}
<span class='comment-count-wrapper'
      data-giscus-term='{{ .Param "giscus_term" }}'
      data-lang='{{ .Lang }}'
      style='display:none;'>
</span>
{{- end -}}

JavaScript dynamically fills the content:

javascript
function updateCommentCounts(counts) {
  const elements = document.querySelectorAll('.comment-count-wrapper');

  elements.forEach(element => {
    const term = element.getAttribute('data-giscus-term');
    const lang = element.getAttribute('data-lang') || 'ko';
    const count = counts[term];

    if (count !== undefined && count > 0) {
      const commentLabel = lang === 'ko' ? '댓글' : 'comments';
      const commentLabelSR = lang === 'ko' ? '댓글 수' : 'comment count';

      // Output with separator
      element.innerHTML = `&nbsp;·&nbsp;<span><span class='sr-only'>${commentLabelSR}: </span><span class='comment-count' aria-live='polite'>${count}</span> ${commentLabel}</span>`;
      element.style.display = '';
    }
  });
}

Key Points:

  • Wrapper remains display:none when comments = 0
  • Displays with &nbsp;·&nbsp; separator when comments ≥ 1
  • This ensures proper display only when comments exist

Scrolling to Comments Section

Add id="comments" in layouts/partials/comments.html:

html
{{- if and (.Site.Params.comments) (ne .Params.comments false) (.Params.giscus_term) -}}
<div id="comments" class="giscus-container">
    <script src="https://giscus.app/client.js" ...>
    </script>
</div>
{{- end -}}

Now scrolls to exact position with #comments anchor:

javascript
// In recent-comments.js
const postUrl = `${langPrefix}/posts/${slug}/#comments`;

CSS Styling

assets/css/extended/comments.css:

css
/* Comment card styles */
.recent-comment-item {
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
  transition: all 0.2s ease;
}

.recent-comment-item:hover {
  border-color: var(--primary);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

/* Improved link contrast */
.recent-comment-link {
  color: var(--primary);  /* Darker color */
  font-weight: 600;  /* Bolder text */
  text-decoration: none;
}

.recent-comment-link:hover {
  color: var(--tertiary);
  text-decoration: underline;  /* Underline on hover */
}

Accessibility Considerations

Various people using computers
Various people using computers
Photo: Unsplash by Product School

Web accessibility ensures all users can access content. Here are the accessibility features implemented.

1. Using ARIA Attributes

html
<!-- Loading state -->
<div class="recent-comments-loading"
     aria-live="polite"
     aria-busy="true">
  Loading comments...
</div>

<!-- Comment list -->
<ul class="recent-comments-list" role="list">
  <!-- Comment items -->
</ul>

<!-- Error state -->
<div class="recent-comments-error"
     role="alert"
     style="display: none;">
  Failed to load comments.
</div>

ARIA Attribute Explanations:

  • aria-live="polite": Screen reader announces changes after current reading completes
  • aria-busy="true": Indicates loading state
  • role="list": Explicitly indicates list (required when using list-style: none in CSS)
  • role="alert": Immediately announces important messages

2. Screen Reader-Only Labels

html
<span>
  <span class='sr-only'>Comment count: </span>
  <span class='comment-count' aria-live='polite'>3</span>
  comments
</span>

CSS:

css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

Screen readers announce “Comment count: 3 comments” but only “3 comments” is visually displayed.

3. Keyboard Navigation

All clickable elements are keyboard accessible:

css
.recent-comment-link:focus {
  outline: 2px solid var(--tertiary);
  outline-offset: 2px;
  border-radius: 2px;
}
  • Navigate focus with Tab key
  • Activate links with Enter key
  • Clear focus indication
html
<a href="/posts/slug/#comments"
   aria-label="Go to post">
  View post →
</a>

Use clear text like “View post” instead of “Click here”.

Performance Optimization

Infographic showing stale-while-revalidate caching pattern - user request quickly hits cache while background refresh happens simultaneously
Infographic showing stale-while-revalidate caching pattern - user request quickly hits cache while background refresh happens simultaneously
Created by: NanoBanana

1. Caching Strategy

5-minute TTL + Stale-While-Revalidate:

  • First visit: API call → store in localStorage
  • Revisit within 5 minutes: instant display from cache
  • After 5 minutes: display cache + background update
javascript
// Cache hit: 0ms (instant!)
// Background update: ~300ms (user doesn't wait)

2. Deferred Script Loading

html
<script src="/js/comment-counts.js" defer></script>
<script src="/js/recent-comments.js" defer></script>
<script src="/js/all-comments.js" defer></script>

defer attribute:

  • Doesn’t block HTML parsing
  • Faster page load
  • Execution guaranteed before DOMContentLoaded

3. Minimizing API Calls

Before (inefficient):

javascript
// Individual API call for each post
posts.forEach(post => {
  fetch(`/api/comments/${post.id}`);
});

After (efficient):

javascript
// Fetch all data in single request
const allDiscussions = await fetchAllDiscussions();
const countMap = buildCountMap(allDiscussions);

GraphQL allows fetching comment information for 50 discussions in a single request.

4. Rate Limit Management

GitHub API rate limits:

  • Without authentication: 60 requests/hour
  • Personal Access Token: 5000 requests/hour

With 5-minute caching, maximum 12 calls per hour:

  • 60 requests/hour ÷ 12 = 5 page views/5 minutes
  • PAT provides ample headroom

5. Conditional Rendering

javascript
// Don't manipulate DOM when no comments
if (count !== undefined && count > 0) {
  element.innerHTML = `...`;
  element.style.display = '';
}

Prevents unnecessary DOM updates to reduce browser reflow.

Performance Measurement Results

Actual measurements (Chrome DevTools):

First Page Load:

  • HTML download: ~150ms
  • JavaScript execution: ~50ms
  • API call: ~300ms
  • Total Time to Interactive: ~500ms

Cached Second Visit:

  • HTML download: ~100ms (CDN cache)
  • JavaScript execution: ~50ms
  • localStorage read: ~1ms
  • Total Time to Interactive: ~150ms

Background Update:

  • Users don’t wait
  • Latest data shows on next page load

Troubleshooting

Issue 1: Hugo Build Error

Error: access denied: "GITHUB_TOKEN" is not whitelisted in policy "security.funcs.getenv"

Solution:

yaml
# config.yaml
security:
  funcs:
    getenv:
      - ^GITHUB_TOKEN$

Issue 2: Comment Counts Not Showing

Browser console shows:

[Comment Counts] Found 0 comment count elements

Cause: Hugo template conditional preventing element rendering

Solution: Remove conditional from layouts/partials/post_meta.html

Issue 3: Environment Variables Not Working on Cloudflare Pages

Cause: Environment variables don’t apply to existing deployments

Solution: Redeploy required

bash
git commit --allow-empty -m "Trigger rebuild"
git push

Issue 4: CORS Error

GitHub GraphQL API supports CORS, so there shouldn’t be issues. If errors occur:

Check:

  • API endpoint is https://api.github.com/graphql
  • Content-Type: application/json header is present
  • Browser is up-to-date

Issue 5: Rate Limit Exceeded

json
{
  "message": "API rate limit exceeded"
}

Temporary fix:

  • Clear localStorage cache: localStorage.clear()
  • Refresh page

Permanent fix:

  • Set up Personal Access Token
  • Increase cache duration (10 minutes, 15 minutes, etc.)

Lessons Learned and Future Improvements

Lessons Learned

  1. GraphQL Efficiency: Could fetch exactly the data needed more efficiently than RESTful API.

  2. Importance of Caching: Stale-while-revalidate pattern secured both performance and freshness.

  3. Early Accessibility Consideration: Implementing accessibility from the start requires minimal additional cost.

  4. Dynamic Features on Static Sites: SSG and dynamic features can coexist through client-side processing.

Future Improvement Plans

1. Real-time Updates

Currently using 5-minute caching, considering real-time updates via WebSocket or Server-Sent Events (SSE).

javascript
// Could be implemented like this
const eventSource = new EventSource('/api/comments/stream');
eventSource.onmessage = (event) => {
  const newComment = JSON.parse(event.data);
  updateUI(newComment);
};

2. Comment Search Feature

Adding search functionality to the all comments page would be useful.

javascript
function searchComments(query) {
  return allComments.filter(comment =>
    comment.bodyText.toLowerCase().includes(query.toLowerCase())
  );
}

3. Comment Notifications

Considering adding notifications for new comments.

4. Include Replies in Count

Currently only counting top-level comments, would need to modify GraphQL query to include replies:

graphql
comments(last: 100) {
  totalCount
  nodes {
    replies(last: 100) {
      totalCount
    }
  }
}
Static website and dynamic features perfectly integrated - implementing both speed and interactivity
Static website and dynamic features perfectly integrated - implementing both speed and interactivity
Created by: NanoBanana

Conclusion

Dynamic features can be fully implemented on static sites. The key points:

  1. Leverage APIs: Fetch data using external APIs like GitHub GraphQL API
  2. Effective Caching: Optimize performance with localStorage + stale-while-revalidate
  3. Ensure Accessibility: Design considering all users
  4. Gradual Improvement: Start with basic features and improve incrementally

I hope this article helps those looking to build a comment system on a static site. Feel free to leave comments below if you have questions!

References