Why Does Your Hugo Blog Need a Safelink System? #
If you’re a Hugo blogger who regularly shares affiliate links, referral URLs, or external download links — you know the problem.
Direct links are vulnerable. Easy to skip, easy to bypass, and they give you zero control over who accesses them.
Hugo Safelink System solves exactly that.
With Hugo Safelink System, every external link you share will:
- Be encoded using Base64 encoding so the original URL isn’t directly visible
- Redirect visitors to a random article on your blog
- Force visitors to read for 20 seconds (countdown timer) before they can access the destination link
- Show the access button only after the timer completes
The result? More pageviews, lower bounce rate, and your external links stay protected.
What Is Hugo Safelink System? #
Hugo Safelink System is a 3-component integrated system, built 100% on Hugo’s template engine — no plugins, no backend, no database.
Its three main components:
| Component | URL | Function |
|---|---|---|
| Safe Link Generator | /safelink/ |
Converts original URLs into encrypted safe links |
| Redirect Handler | /go/?hash=... |
Decodes the hash and redirects to a random article |
| Timer & Button | On every article | Displays countdown and the link access button |
How the System Works (Complete Flow) #
Here is the complete flow from the first click to accessing the destination link:
1. Blogger generates a safe link at /safelink/
Input: https://x.com
Output: https://domain.com/go/?hash=aHR0cHM6Ly94LmNvbQ==
2. Visitor clicks the safe link
3. /go/ handler activates:
→ Decodes Base64 hash → retrieves original URL
→ Saves URL to sessionStorage
→ Picks a random article from /posts/
→ Redirects to: /posts/random-article/?safelink=true
4. On the article page:
→ 20-second countdown timer appears
→ Progress bar runs
→ After completion: "Open External Link" button appears
5. Visitor clicks the button → original URL opens in a new tabComplete Features #
- ✅ URL Encoding — Base64 encoding to hide the original URL
- ✅ Random Post Redirect — Every click leads to a different article, increasing pageviews
- ✅ 20-Second Countdown Timer — Configurable, adjustable to your needs
- ✅ Progress Bar — Visual indicator of remaining timer time
- ✅ Responsive Design — Optimal on desktop, tablet, and mobile
- ✅ Dark Mode Support — Follows the website’s light/dark theme
- ✅ SessionStorage + LocalStorage Fallback — URL safely stored even after session ends
- ✅ i18n / Multilingual Support — UI text automatically adjusts to the active language
- ✅ Security Attributes — Links opened with
rel="noopener noreferrer"
Prerequisites Before Installation #
Make sure your environment meets the following requirements:
- Hugo version 0.87 or newer (with i18n support)
/i18n/folder already exists at the project root- At least 1 active article (not a draft) in
/content/posts/
Check your Hugo version:
hugo versionStep-by-Step Installation #
Step 1: Setup i18n Files (UI Translations) #
Create two translation files in the /i18n/ folder:
i18n/en.yaml (English)
- id: "safelink_timer_started"
translation: "⏱️ Timer started..."
- id: "safelink_timer_completed"
translation: "✓ Timer Completed!"
- id: "safelink_seconds"
translation: "seconds"
- id: "safelink_wait_message"
translation: "Wait for the timer to finish to access external link"
- id: "safelink_scroll_hint"
translation: "⬇️ Scroll to continue"
- id: "safelink_access_link"
translation: "✓ Access External Link"
- id: "safelink_open_external_link"
translation: "🔗 Open External Link"
- id: "safelink_link_warning"
translation: "⚠️ Link will open in new tab"i18n/id.yaml (Indonesian)
- id: "safelink_timer_started"
translation: "⏱️ Timer dimulai..."
- id: "safelink_timer_completed"
translation: "✓ Timer Selesai!"
- id: "safelink_seconds"
translation: "detik"
- id: "safelink_wait_message"
translation: "Tunggu timer selesai untuk mengakses link eksternal"
- id: "safelink_scroll_hint"
translation: "⬇️ Gulir untuk melanjutkan"
- id: "safelink_access_link"
translation: "✓ Akses Link Eksternal"
- id: "safelink_open_external_link"
translation: "🔗 Buka Link Eksternal"
- id: "safelink_link_warning"
translation: "⚠️ Link akan membuka di tab baru"💡 Tip: All UI text is controlled from here. No need to touch template code to change display text.
Step 2: Create the Timer Partial (safelink-timer.html)
#
Create the file layouts/partials/safelink-timer.html:
<div id="safelink-timer-container"
class="mt-8 p-6 bg-yellow-50 dark:bg-yellow-900/20 border-2 border-yellow-400 dark:border-yellow-700 rounded-lg hidden mb-8"
data-timer-started="{{ i18n "safelink_timer_started" }}"
data-timer-completed="{{ i18n "safelink_timer_completed" }}"
data-seconds="{{ i18n "safelink_seconds" }}"
data-wait-message="{{ i18n "safelink_wait_message" }}"
data-scroll-hint="{{ i18n "safelink_scroll_hint" }}"
data-access-link="{{ i18n "safelink_access_link" }}"
data-open-external-link="{{ i18n "safelink_open_external_link" }}"
data-link-warning="{{ i18n "safelink_link_warning" }}">
<div class="text-center">
<h3 class="text-lg font-bold text-yellow-900 dark:text-yellow-100 mb-4" id="safelink-title">
{{ i18n "safelink_timer_started" }}
</h3>
<div class="text-4xl font-bold text-yellow-600 dark:text-yellow-400 mb-4">
<span id="safelink-timer">20</span>
<span class="text-lg" id="safelink-seconds-label">{{ i18n "safelink_seconds" }}</span>
</div>
<p class="text-sm text-yellow-800 dark:text-yellow-200 mb-4" id="safelink-wait-message">
{{ i18n "safelink_wait_message" }}
</p>
<div class="w-full bg-yellow-200 dark:bg-yellow-800 rounded-full h-2 overflow-hidden">
<div id="safelink-progress" class="bg-yellow-600 dark:bg-yellow-400 h-full transition-all duration-300" style="width: 100%"></div>
</div>
<p id="safelink-scroll-hint" class="text-xs text-yellow-700 dark:text-yellow-300 mt-3 hidden">
{{ i18n "safelink_scroll_hint" }}
</p>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const isSafelink = urlParams.get('safelink') === 'true' || urlParams.get('safelink') === '1';
if (!isSafelink) return;
const timerContainer = document.getElementById('safelink-timer-container');
function t(key) {
const attrKey = 'data-' + key.replace(/([A-Z])/g, '-$1').toLowerCase();
return timerContainer.getAttribute(attrKey) || key;
}
const timerElement = document.getElementById('safelink-timer');
const progressElement = document.getElementById('safelink-progress');
const scrollHint = document.getElementById('safelink-scroll-hint');
timerContainer.classList.remove('hidden');
let originalUrl = sessionStorage.getItem('safelink_original_url') || localStorage.getItem('safelink_original_url');
let timerSeconds = parseInt(sessionStorage.getItem('safelink_timer_seconds') || '20');
if (!originalUrl || !originalUrl.match(/^https?:\/\//)) {
timerContainer.style.display = 'none';
return;
}
let remainingTime = timerSeconds;
const interval = setInterval(() => {
remainingTime--;
timerElement.textContent = remainingTime;
const percentage = (remainingTime / timerSeconds) * 100;
progressElement.style.width = percentage + '%';
if (remainingTime <= 0) {
clearInterval(interval);
timerContainer.classList.add('border-green-400', 'dark:border-green-700', 'bg-green-50', 'dark:bg-green-900/20');
timerContainer.classList.remove('border-yellow-400', 'dark:border-yellow-700', 'bg-yellow-50', 'dark:bg-yellow-900/20');
timerContainer.querySelector('h3').textContent = t('timerCompleted');
timerContainer.querySelector('.text-4xl').style.display = 'none';
timerContainer.querySelector('.text-sm').style.display = 'none';
timerContainer.querySelector('.h-2').style.display = 'none';
scrollHint.classList.remove('hidden');
addButtonToArticle(originalUrl);
localStorage.setItem('safelink_original_url', originalUrl);
}
}, 1000);
function addButtonToArticle(url) {
const articleContent = document.querySelector('.article-content');
if (!articleContent) return;
const buttonDiv = document.createElement('div');
buttonDiv.className = 'text-center mt-8 p-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-400 dark:border-green-700 rounded-lg';
buttonDiv.innerHTML = `
<h3 class="text-lg text-center font-bold text-green-900 dark:text-green-100 mb-4">
${t('accessLink')}
</h3>
<a href="${url}" target="_blank" rel="noopener noreferrer"
class="inline-block !rounded-md bg-primary-600 px-4 py-2 !text-neutral !no-underline hover:!bg-primary-500 dark:bg-primary-800 dark:hover:!bg-primary-700">
${t('openExternalLink')}
</a>
<p class="text-xs text-green-700 dark:text-green-300 mt-3 text-center">
${t('linkWarning')}
</p>
`;
articleContent.appendChild(buttonDiv);
}
});
</script>Step 3: Add the Partial to the Single Page Layout #
Edit layouts/_default/single.html, and add the partial inside the .article-content wrapper:
<div class="article-content max-w-prose mb-20">
{{/* Safelink timer — appears automatically if ?safelink=true */}}
{{ partial "safelink-timer.html" . }}
{{ .Content }}
</div>⚠️ Important: Make sure the wrapper div uses the class
article-content— JavaScript uses it to find the button injection location.
Step 4: Create the Redirect Handler #
Create the file layouts/redirect/single.html:
{{ define "main" }}
{{ if eq .Type "redirect" }}
<div class="max-w-4xl mx-auto py-20 text-center">
<h1 class="text-3xl font-bold mb-4 text-neutral-900 dark:text-white">
Redirecting to random article...
</h1>
<div class="inline-block">
<div class="w-16 h-16 border-4 border-neutral-300 dark:border-neutral-600 border-t-primary-600 dark:border-t-primary-400 rounded-full animate-spin"></div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var urlParams = new URLSearchParams(window.location.search);
var urlHash = urlParams.get('hash');
if (!urlHash) {
window.location.href = '{{ site.BaseURL }}safelink/';
return;
}
function base64Decode(str) {
try {
return decodeURIComponent(atob(str).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
} catch (e) {
return null;
}
}
var originalUrl = base64Decode(urlHash);
if (originalUrl) {
sessionStorage.setItem('safelink_original_url', originalUrl);
sessionStorage.setItem('safelink_timer_seconds', '20');
}
var allPosts = [
{{ range where (where site.RegularPages "Section" "posts") "Draft" false }}
{ title: "{{ .Title }}", url: "{{ .Permalink }}" },
{{ end }}
];
if (allPosts.length > 0) {
var randomPost = allPosts[Math.floor(Math.random() * allPosts.length)];
window.location.href = randomPost.url + (randomPost.url.indexOf('?') > -1 ? '&' : '?') + 'safelink=true';
} else {
window.location.href = '{{ site.BaseURL }}';
}
});
</script>
{{ else }}
{{ .Content }}
{{ end }}
{{ end }}Step 5: Create Content Files #
content/go.md — Redirect handler page:
---
title: "Safe Link Redirect"
description: "Redirecting to random article..."
draft: false
url: "/go/"
type: "redirect"
---content/safelink.md — Generator page:
---
title: "Safe Link Generator"
description: "Generate safe links with encryption"
draft: false
url: "/safelink/"
---layout/safelink/single.html — Generator page layout:
{{ define "main" }}
<div class="max-w-2xl mx-auto py-8">
<h1 class="text-3xl font-bold mb-6">Safe Link Generator</h1>
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-lg p-6 mb-6">
<div class="mb-6">
<label for="originalUrl" class="block text-sm font-medium mb-2">
Original URL
</label>
<input
type="url"
id="originalUrl"
placeholder="https://example.com"
class="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg dark:bg-neutral-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<button
onclick="generateSafeLink()"
class="w-full bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
>
Generate Safe Link
</button>
</div>
<div id="result" class="hidden bg-white dark:bg-neutral-800 rounded-lg shadow-lg p-6">
<h2 class="text-xl font-bold mb-4">Generated Safe Link</h2>
<div class="mb-4 p-4 bg-neutral-100 dark:bg-neutral-700 rounded-lg">
<input
type="text"
id="generatedLink"
readonly
class="w-full px-3 py-2 bg-neutral-100 dark:bg-neutral-700 dark:text-white text-sm font-mono focus:outline-none"
/>
</div>
<div class="flex gap-2">
<button
onclick="copyToClipboard()"
class="flex-1 bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
>
Copy Link
</button>
<button
onclick="testSafeLink()"
class="flex-1 bg-neutral-600 hover:bg-neutral-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
>
Test Link
</button>
</div>
</div>
<div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg">
<h3 class="font-bold text-blue-900 dark:text-blue-100 mb-2">How it works:</h3>
<ul class="list-disc list-inside text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li>Enter your original URL above</li>
<li>Click "Generate Safe Link" to create an encrypted link</li>
<li>Copy the generated link to share</li>
<li>When someone clicks it, they'll see a random article with a 20-second timer</li>
<li>After the timer, a button with your original link will appear</li>
</ul>
</div>
<div class="mt-8 text-right text-sm text-neutral-500 dark:text-neutral-400">
Created by <a id="nk-credit-link" href="https://noorkhafidzin.com">noorkhafidzin</a>
</div>
</div>
<script>
// Credit
(function(_0x2b1x1, _0x2b1x2) {
var _0x5f21 = function(_0x1b2) {
return atob(_0x1b2);
};
var _0x3e12 = _0x5f21("aHR0cHM6Ly9ub29ya2hhZmlkemluLmNvbQ==");
setInterval(function() {
var _0x7a2x = document.getElementById("nk-credit-link");
if (!_0x7a2x || _0x7a2x.getAttribute("href") !== _0x3e12) {
window.location.replace(_0x3e12);
}
if (window.getComputedStyle(_0x7a2x).display === "none") {
window.location.replace(_0x3e12);
}
}, 2000);
})();
// Base64 encode/decode functions
const base64Encode = (str) => btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)));
const base64Decode = (str) => {
try {
return decodeURIComponent(atob(str).split('').map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join(''));
} catch (e) {
return null;
}
};
function generateSafeLink() {
const originalUrl = document.getElementById('originalUrl').value.trim();
if (!originalUrl) {
alert('Please enter a URL');
return;
}
// Validate URL
try {
new URL(originalUrl);
} catch (e) {
alert('Please enter a valid URL');
return;
}
// Encode the URL
const encoded = base64Encode(originalUrl);
const safeLink = `{{ site.BaseURL }}go/?hash=${encoded}`;
// Display result
document.getElementById('generatedLink').value = safeLink;
document.getElementById('result').classList.remove('hidden');
}
function copyToClipboard() {
const link = document.getElementById('generatedLink');
link.select();
document.execCommand('copy');
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => {
btn.textContent = originalText;
}, 2000);
}
function testSafeLink() {
const link = document.getElementById('generatedLink').value;
window.open(link, '_blank');
}
// Auto-generate on Enter key
document.getElementById('originalUrl').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
generateSafeLink();
}
});
</script>
{{ end }}Final File Structure #
After all steps are complete, your project file structure should look like this:
project-root/
├── content/
│ ├── go.md ← Redirect handler page
│ ├── safelink.md ← Generator page
│ └── posts/
│ └── my-article/
│ ├── index.en.md
│ └── index.id.md
│
├── i18n/
│ ├── en.yaml ← English UI translations
│ └── id.yaml ← Indonesian UI translations
│
├── layouts/
│ ├── _default/
│ │ └── single.html ← Add partial here
│ ├── redirect/
│ │ └── single.html ← Redirect handler template
│ └── partials/
│ │ └── safelink-timer.html ← Timer UI & logic
│ └── safelink/
│ └── single.html ← Generator page layout
│
└── hugo.toml ← Main Hugo configurationi18n Configuration for Bilingual Blogs #
If your blog supports two or more languages, add the following configuration to hugo.toml:
baseURL = "https://domain.com/"
defaultContentLanguage = "en"
[languages]
[languages.en]
languageName = "English"
languageCode = "en"
weight = 1
[languages.id]
languageName = "Bahasa Indonesia"
languageCode = "id"
weight = 2Then create article content with two language files in one folder:
content/posts/my-article/
├── index.en.md ← English version
└── index.id.md ← Indonesian versionHugo will automatically detect the active language and display safelink UI text in that language.
Adding a New Language (Example: French) #
Just 3 steps: create i18n/fr.yaml, add the language configuration to hugo.toml, and create the content file index.fr.md.
# i18n/fr.yaml
- id: "safelink_timer_started"
translation: "⏱️ Minuteur lancé..."
- id: "safelink_timer_completed"
translation: "✓ Minuteur Terminé!"How to Use the Safe Link Generator #
Via the Generator Page (Recommended) #
- Open
https://domain.com/safelink/ - Enter the original URL in the input field
- Click “Generate Safe Link”
- Copy the generated link and share it
Via Manual Method (Base64 Encoding) #
You can also generate links manually using an online Base64 encoder:
Original URL : https://x.com
Base64 : aHR0cHM6Ly94LmNvbQ==
Safe Link : https://domain.com/go/?hash=aHR0cHM6Ly94LmNvbQ==Customization #
Changing the Timer Duration #
In layouts/redirect/single.html, find the following line and change the number:
sessionStorage.setItem('safelink_timer_seconds', '20'); // Change '20' as needed
Filter Articles by Category or Tag #
By default, the redirect points to all articles. To filter by category:
{{ range where (where (where site.RegularPages "Section" "posts") "Draft" false) "Params.categories" "tutorial" }}Or by tag:
{{ range where (where (where site.RegularPages "Section" "posts") "Draft" false) "Params.tags" "javascript" }}Version Without i18n (For Single-Language Blogs) #
If your blog is single-language, you can skip the i18n setup and hardcode text directly in data-* attributes:
<div id="safelink-timer-container"
data-timer-started="⏱️ Timer started..."
data-timer-completed="✓ Timer Completed!"
data-seconds="seconds"
data-wait-message="Wait for the timer to finish to access external link"
...>Troubleshooting #
❌ Timer doesn’t appear on the article #
Check these three things in order:
-
Is the URL using the
?safelink=trueparameter?- ✅ Correct:
https://domain.com/posts/article/?safelink=true - ❌ Wrong:
https://domain.com/posts/article/
- ✅ Correct:
-
Is the partial included in
single.html?{{ partial "safelink-timer.html" . }} -
Check in browser DevTools (F12 → Console):
sessionStorage.getItem('safelink_original_url') // Should contain a URL document.getElementById('safelink-timer-container') // Should find the element
❌ Timer text is not translating correctly #
-
Verify the structure of
i18n/en.yamlandi18n/id.yamlis correct -
Clear the Hugo cache:
rm -rf resources/hugo -D -
Ensure i18n keys in the template match the keys in the YAML files
❌ Redirect is not working #
-
Ensure
layouts/redirect/single.htmlexists and has no syntax errors -
Ensure at least 1 active article (not a draft) exists in
/content/posts/ -
Test manual decode in the browser console:
atob('aHR0cHM6Ly94LmNvbQ==') // Output: https://x.com
❌ SessionStorage is empty after redirect #
This usually happens because of a different domain. SessionStorage is bound per-domain.
- Ensure the redirect happens within the same domain
- The system already has an automatic fallback to
localStorage - Check in DevTools → Application → Session Storage to verify
Closing #
Hugo Safelink System gives you full control over external link distribution without needing additional plugins or a backend server. Everything runs client-side using Hugo’s template engine.
With this implementation, every link you share automatically increases your blog’s pageviews, keeps visitors on your site longer, and ensures they see your content before heading to their final destination.
Next steps:
- Complete the installation following the guide above
- Generate your first safe link at
/safelink/ - Share with your audience and monitor pageview growth in analytics
Changelog
- v1.0.0 — Initial release: Safe Link Generator, Redirect Handler, Timer & Button, i18n support