Skip to main content
  1. Posts/

How to Build a Safe Link System in Hugo with Countdown Timer & Multilingual Support

·2371 words·12 mins·
Noor Khafidzin
Author
Noor Khafidzin
A homelab enthusiast obsessed with system efficiency and the art of troubleshooting.
Table of Contents

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.


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 tab

Complete 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 version

Step-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.


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 configuration

i18n 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 = 2

Then create article content with two language files in one folder:

content/posts/my-article/
├── index.en.md   ← English version
└── index.id.md   ← Indonesian version

Hugo 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é!"

  1. Open https://domain.com/safelink/
  2. Enter the original URL in the input field
  3. Click “Generate Safe Link”
  4. 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:

  1. Is the URL using the ?safelink=true parameter?

    • ✅ Correct: https://domain.com/posts/article/?safelink=true
    • ❌ Wrong: https://domain.com/posts/article/
  2. Is the partial included in single.html?

    {{ partial "safelink-timer.html" . }}
  3. 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
#

  1. Verify the structure of i18n/en.yaml and i18n/id.yaml is correct

  2. Clear the Hugo cache:

    rm -rf resources/hugo -D
  3. Ensure i18n keys in the template match the keys in the YAML files


❌ Redirect is not working
#

  1. Ensure layouts/redirect/single.html exists and has no syntax errors

  2. Ensure at least 1 active article (not a draft) exists in /content/posts/

  3. 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:

  1. Complete the installation following the guide above
  2. Generate your first safe link at /safelink/
  3. 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

Related


Load Comments