Adding a Short Link System to a Purely Static Blog
The Problem
My blog post URLs have always been long:
https://yuxu.ge/blog/post.html?p=posts/gol/2026-02-25-cross-experiment-en
When I share them on social media, these links take up half the message and look cluttered. I wanted a short link system, something like this:
https://yuxu.ge/s/fewt3
But my blog is a purely static site (hosted on GitHub Pages), with no backend server. This means traditional short link solutions — where a request hits a server, which queries a database and issues a 302 redirect — are all off the table.
Design Approach
Without a server, the logic must occur at build time or in the browser.
The core idea is simple: generate a shortcode for each article at build time, then create a corresponding HTML file to handle the redirect. GitHub Pages will automatically route a request for /s/fewt3/ to /s/fewt3/index.html, so all I need to do is place a redirect page at that location.
Build Time (Node.js)
══════════════════════════════════════════════════
[posts.json] ──► [build-shortlinks.js]
│
┌────────────┼────────────┐
▼ ▼ ▼
shortlinks.json /s/abcde/ /s/fewt3/
(slug → code) index.html index.html
(redirect) (redirect)
Browser Side
══════════════════════════════════════════════════
Article page loads
│
▼
fetch shortlinks.json (one-time)
│
▼
Look up current slug → get shortcode
│
▼
Inject "Copy Short Link" button
Shortcode Generation Algorithm
The shortcode generation needs to meet two requirements: it must be deterministic (the same slug always produces the same code) and have a low collision rate.
I chose a solution based on SHA-256:
"posts/2026/2026-02-20-git-crypt-private-notes-zh" (slug)
│
▼ SHA-256
"a7f3e1b9c4d8..." (64-char hex string)
│
▼ Take first 8 hex characters
"a7f3e1b9"
│
▼ parseInt(..., 16)
2818769337
│
▼ .toString(36)
"4g9kfp"
│
▼ padStart(5, '0').slice(0, 5)
"4g9kf" (final shortcode)
The core implementation is just three lines:
function generateShortCode(slug, offset = 0) {
const hash = crypto.createHash('sha256').update(slug).digest('hex');
const num = parseInt(hash.slice(offset, offset + 8), 16);
return num.toString(36).padStart(5, '0').slice(0, 5);
}
Collision Handling
A 5-character base36 string offers roughly 60 million combinations (36^5 ≈ 60,466,176), making collisions unlikely. Still, a robust system must handle them.
My strategy is a sliding window: if the code generated from the hex slice [0:8] conflicts with an existing one, I try [8:16], then [16:24], and so on. A 64-character SHA-256 hex string provides 7 retry opportunities, which is more than enough.
for (const slug of slugs) {
let offset = 0;
let code = generateShortCode(slug, offset);
while (codeToSlug[code] && codeToSlug[code] !== slug) {
offset += 8;
if (offset >= 56) {
console.error(`FATAL: Cannot resolve collision for ${slug}`);
process.exit(1);
}
code = generateShortCode(slug, offset);
}
codeToSlug[code] = slug;
slugToCode[slug] = code;
}
In practice, this approach generated 224 codes for 224 articles with no collisions. By the birthday problem, collision probability is roughly n² / (2 × 60.5M) — about 0.04% for 224 articles, and roughly 17% for 5,000. The sliding window handles any that occur.
The Redirect Page
Each shortcode corresponds to an index.html file responsible for instantly redirecting to the original article:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/blog/2026/2026-02-20-git-crypt-private-notes-zh.html">
<link rel="canonical" href="https://yuxu.ge/blog/2026/...">
<title>Redirecting...</title>
</head>
<body>
<script>window.location.replace('/blog/2026/...');</script>
<noscript><p>Redirecting...</p></noscript>
</body>
</html>
I use both meta http-equiv="refresh" and window.location.replace(). The former handles users with JavaScript disabled; the latter is faster and avoids a history entry, so back-button users don't land on the redirect. The canonical tag marks the original URL as authoritative.
Frontend Integration
On the article page, I needed to display a "Copy Short Link" button next to the title.
async function injectShortLinkButton() {
if (!postName) return;
if (document.getElementById('short-link-btn')) return;
// Lazy-load the mapping file (one-time)
if (!window._shortlinks) {
try {
const resp = await fetch('/blog/shortlinks.json');
window._shortlinks = await resp.json();
} catch { window._shortlinks = {}; }
}
const code = window._shortlinks[postName];
if (!code) return;
const shortUrl = location.origin + '/s/' + code;
// Inject the button after the h1
const h1 = document.querySelector('article h1');
if (!h1) return;
const btn = document.createElement('button');
btn.id = 'short-link-btn';
btn.className = 'short-link-btn';
btn.textContent = '🔗';
btn.title = shortUrl;
btn.onclick = async () => {
await navigator.clipboard.writeText(shortUrl);
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = '🔗', 1500);
};
h1.after(btn);
}
For static HTML pages generated at build time, the button is injected directly into the HTML by build-static.js.
Link Persistence: Short Links That Never Break
After the system was up and running, a question emerged: what happens if I change the algorithm in the future?
For example, expanding from 5 to 6 characters would produce completely different codes, breaking every link I'd shared.
To solve this, I introduced a persistent lock file: _tools/shortlinks-locked.json.
Build Process
══════════════════════════════════════════════════
shortlinks-locked.json posts.json
(locked slug→code map) (all posts)
│ │
└───────┬───────────────┘
▼
build-shortlinks.js
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
Use locked New posts go shortlinks-locked.json
codes through (new codes appended
directly algorithm automatically)
The core logic is:
- During the build, first load all mappings from the lock file.
- For any slug already in the lock file, use its existing shortcode directly without recalculating it.
- Only run the generation algorithm for new slugs.
- When new codes are generated, automatically write the complete map back to the lock file.
// Load locked mappings
const locked = loadLockedCodes();
// Use locked codes directly
for (const slug of slugs) {
if (locked[slug]) {
codeToSlug[locked[slug]] = slug;
slugToCode[slug] = locked[slug];
}
}
// Only generate codes for new slugs
for (const slug of slugs) {
if (slugToCode[slug]) continue; // Already locked, skip
// ... run generation algorithm ...
}
// Automatically lock new codes
if (!skipLock && newCount > 0) {
fs.writeFileSync(LOCKED_JSON, JSON.stringify(slugToCode, null, 2) + '\n');
}
This way, no matter how I change the algorithm in the future, old links will always remain valid. A --no-lock parameter allows me to skip the locking step during debugging.
Build Integration
I added a step to my build.sh:
echo "=== Building Short Links ==="
node $TOOLS_DIR/build-shortlinks.js
And added cleanup steps to clear.sh:
rm -rf s/
rm -f blog/shortlinks.json
The output looks like this:
$ node _tools/build-shortlinks.js
Generated: 224 short links (224 locked, 0 new)
Generated: blog/shortlinks.json
Summary
This system embodies one principle: do everything at build time, zero cost at runtime. No database, no server, no API calls—just pre-generated HTML files and a JSON map.
The persistent lock file is the linchpin: it reconciles two seemingly contradictory needs—"change the algorithm anytime" and "links never break."