This tutorial shows how to add a “like” button to your Hugo blog posts using Google Sheets as a free, serverless backend. Users can click to like posts, and the count persists across sessions. 總之,換個方法可以按文章讚了。
人類前言
失序了,AI 做太好了。
想說 LikeCoin for blog 已死 (反正我也不是靠那個賺錢,目前主要可能還是廣告),要改個方法實現小互動。因為此網站是架在 github page 上的靜態網頁無法儲存動態資訊,想說可以用 google sheet 當作後端紀錄愛心數。用了破英文丟給 github copilot 的 Claude Opus 4.5 做。沒想到 one-shot 一次到位。
prompt (非常之破的英文,勿鞭)
help plan to add like functionality by using google sheet. the key is the post url and value is total likes of the specific post. update the like when clicking and fetch like count when load the article
成果
連這篇文章也直接請它整理
整理文章
回顧 2023 年還在痛苦的使用 (無法接受新事物的現代人),雖然兩年多但也算進步頗快了。有了 AI 我已成廢人。
Overview
The solution consists of three parts:
- Google Sheets - Stores the URL and like count for each post
- Google Apps Script - Provides a REST API to read/write like counts
- Hugo Template - Renders the like button and handles user interaction
Features
- ❤️ Animated heart button with like count
- 🔄 Fetches count on page load
- 🔒 Prevents duplicate likes using localStorage
- 🌙 Dark mode support
- 🔔 Toast notifications
- 💰 Completely free (uses Google Sheets as database)
Step 1: Create Google Sheet
- Go to Google Sheets and create a new spreadsheet
- Rename the first sheet tab to
Likes(important: exact name required) - Add headers in the first row:
- Cell A1:
url - Cell B1:
likes
- Cell A1:
Your sheet should look like this:
| url | likes |
|---|---|
Step 2: Set up Google Apps Script
- In your Google Sheet, go to Extensions > Apps Script
- Delete any existing code in the editor
- Paste the following code:
const SHEET_NAME = 'Likes';
function doGet(e) {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
const url = e.parameter.url;
if (!url) {
return createJsonResponse({ error: 'Missing url parameter' });
}
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0] === url) {
return createJsonResponse({ url: url, likes: data[i][1] });
}
}
return createJsonResponse({ url: url, likes: 0 });
}
function doPost(e) {
const lock = LockService.getScriptLock();
try {
lock.waitLock(10000);
} catch (e) {
return createJsonResponse({ error: 'Could not obtain lock' });
}
try {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
const url = e.parameter.url;
if (!url) {
return createJsonResponse({ error: 'Missing url parameter' });
}
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0] === url) {
const newLikes = (parseInt(data[i][1]) || 0) + 1;
sheet.getRange(i + 1, 2).setValue(newLikes);
return createJsonResponse({ url: url, likes: newLikes });
}
}
// New entry - URL not found, add it
sheet.appendRow([url, 1]);
return createJsonResponse({ url: url, likes: 1 });
} finally {
lock.releaseLock();
}
}
function createJsonResponse(data) {
return ContentService.createTextOutput(JSON.stringify(data))
.setMimeType(ContentService.MimeType.JSON);
}
- Click Save (Ctrl+S) and name the project (e.g., “Blog Likes”)
How the Script Works
doGet(e): Handles GET requests to fetch the current like count for a URLdoPost(e): Handles POST requests to increment the like countLockService: Prevents race conditions when multiple users like simultaneously
Step 3: Deploy as Web App
- Click Deploy > New deployment
- Click the gear icon ⚙️ next to “Select type” and choose Web app
- Configure the deployment:
- Description: Blog Like Counter (or any name)
- Execute as: Me
- Who has access: Anyone
- Click Deploy
- Click Authorize access and follow the prompts to grant permissions
- Copy the Web app URL - it looks like:
https://script.google.com/macros/s/AKfycbw.../exec
⚠️ Important: Every time you modify the Apps Script code, you need to create a New deployment for changes to take effect.
Step 4: Create Hugo Template
Create a new partial template at layouts/partials/like-button.html:
{{- if .Site.Params.likeSheetUrl -}}
<div id="like-button-container" class="like-button-wrapper">
<button id="like-btn" class="like-btn" onclick="toggleLike()" title="Like this post">
<i id="like-icon" class="far fa-heart"></i>
<span id="like-count">0</span>
</button>
</div>
<style>
.like-button-wrapper {
display: flex;
justify-content: center;
margin: 1.5rem 0;
}
.like-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
font-size: 1rem;
border: 2px solid #e74c3c;
border-radius: 2rem;
background: transparent;
color: #e74c3c;
cursor: pointer;
transition: all 0.3s ease;
font-family: inherit;
}
.like-btn:hover {
background: rgba(231, 76, 60, 0.1);
transform: scale(1.05);
}
.like-btn.liked {
background: #e74c3c;
color: white;
}
.like-btn.liked i {
animation: heartBeat 0.3s ease-in-out;
}
.like-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@keyframes heartBeat {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
/* Dark mode support */
.dark .like-btn {
border-color: #ff6b6b;
color: #ff6b6b;
}
.dark .like-btn:hover {
background: rgba(255, 107, 107, 0.1);
}
.dark .like-btn.liked {
background: #ff6b6b;
color: white;
}
</style>
<script>
(function() {
const LIKE_SHEET_URL = '{{ .Site.Params.likeSheetUrl }}';
const POST_URL = '{{ .Permalink }}';
const STORAGE_KEY = 'liked_posts';
function hasLiked() {
try {
const likedPosts = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
return likedPosts.includes(POST_URL);
} catch (e) {
return false;
}
}
function saveLikedState() {
try {
const likedPosts = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (!likedPosts.includes(POST_URL)) {
likedPosts.push(POST_URL);
localStorage.setItem(STORAGE_KEY, JSON.stringify(likedPosts));
}
} catch (e) {
console.error('Failed to save liked state:', e);
}
}
async function fetchLikeCount() {
try {
const response = await fetch(`${LIKE_SHEET_URL}?url=${encodeURIComponent(POST_URL)}`);
const data = await response.json();
document.getElementById('like-count').textContent = data.likes || 0;
} catch (e) {
console.error('Failed to fetch like count:', e);
}
}
function updateButtonState() {
const btn = document.getElementById('like-btn');
const icon = document.getElementById('like-icon');
if (hasLiked()) {
btn.classList.add('liked');
icon.classList.remove('far');
icon.classList.add('fas');
}
}
window.toggleLike = async function() {
const btn = document.getElementById('like-btn');
const icon = document.getElementById('like-icon');
const countEl = document.getElementById('like-count');
if (btn.disabled) return;
if (hasLiked()) {
// Optional: show notification that user already liked
return;
}
btn.disabled = true;
try {
await fetch(`${LIKE_SHEET_URL}?url=${encodeURIComponent(POST_URL)}`, {
method: 'POST',
mode: 'no-cors'
});
// Optimistically update UI
const currentCount = parseInt(countEl.textContent) || 0;
countEl.textContent = currentCount + 1;
btn.classList.add('liked');
icon.classList.remove('far');
icon.classList.add('fas');
saveLikedState();
// Refetch accurate count
setTimeout(fetchLikeCount, 1000);
} catch (e) {
console.error('Failed to like post:', e);
} finally {
btn.disabled = false;
}
};
document.addEventListener('DOMContentLoaded', function() {
fetchLikeCount();
updateButtonState();
});
})();
</script>
{{- end -}}
Step 5: Include the Template
Add the like button to your single post template. Edit your layouts/_default/single.html or layouts/partials/footer.html:
{{- partial "like-button.html" . -}}
Step 6: Configure Hugo
Add the Web App URL to your config.toml:
[params]
likeSheetUrl = "https://script.google.com/macros/s/YOUR_SCRIPT_ID/exec"
Replace YOUR_SCRIPT_ID with your actual deployment URL from Step 3.
How It Works
Data Flow
┌─────────────┐ GET ?url=... ┌─────────────────┐ Read ┌──────────────┐
│ Browser │ ──────────────────▶ │ Apps Script │ ────────────▶ │ Google Sheet │
│ (Hugo Site)│ ◀────────────────── │ (Web App) │ ◀──────────── │ (Likes) │
└─────────────┘ { likes: 42 } └─────────────────┘ Data └──────────────┘
│
│ POST ?url=...
▼
┌─────────────────┐
│ Increment +1 │
│ Save to Sheet │
└─────────────────┘
Client-Side Storage
The browser’s localStorage tracks which posts the user has liked:
// Example localStorage data
{
"liked_posts": [
"https://example.com/posts/article-1",
"https://example.com/posts/article-2"
]
}
This prevents users from liking the same post multiple times (per browser).
Troubleshooting
CORS Issues
Google Apps Script has limited CORS support. The implementation uses mode: 'no-cors' for POST requests, which means:
- Response content cannot be read
- UI updates optimistically and re-fetches afterward
Like Count Shows 0
- Verify the Web App URL is correct in
config.toml - Check browser console for errors
- Test the API directly:
https://your-script-url/exec?url=test
Changes Not Taking Effect
After modifying the Apps Script:
- Go to Deploy > Manage deployments
- Click Create new deployment (not edit existing)
- Update the URL in
config.tomlif it changed
Conclusion
This solution provides a simple, free way to add like functionality to a Hugo blog. While it’s not suitable for high-traffic sites (Google Sheets has API limits), it works well for personal blogs and small sites.
For production use, consider:
- Adding rate limiting
- Using a proper database (Firebase, Supabase, etc.)
- Implementing server-side validation
References
本文部分內容由Claude Opus 4.5協助生成,作者具備相關專業能力,對 AI 產出內容進行審核與把關,並對文章的正確性負最終責任。若文中有錯誤之處,敬請不吝指正,作者將虛心接受指教並儘速修正。