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
Since there's no server, all the logic has to happen at one of two times: 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
Although a 5-character base36 string has about 60 million possible combinations (36^5 ≈ 60,466,176), making the probability of a collision extremely low, a robust system must account for it.
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 generated 224 unique shortcodes for 224 articles with zero collisions. A quick probability analysis: according to the birthday problem, the collision probability is roughly n² / (2 × 60.5M). For 224 articles, that's only 0.04%. Even with 5,000 articles, it's only about 17% — and the sliding window would resolve any collisions automatically.
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 provides compatibility for users with JavaScript disabled, while the latter is faster and doesn't create an entry in the browser's history — so the user won't get stuck on the redirect page when they hit the back button. The canonical tag tells search engines that the original URL is the authoritative version.
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, what if I expand the shortcode from 5 to 6 characters? Since the algorithm is deterministic, the results from slice(0, 5) and slice(0, 6) would be completely different. All existing shortcodes would change, and every link I'd ever shared would break.
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
The core design principle of this short link system is to do everything at build time for zero cost at runtime. There's no database, no server, and no API calls — just a collection of pre-generated HTML files and a single JSON map.
The persistent lock file was the most critical design decision in the entire system. It elegantly reconciles two seemingly contradictory requirements: "the algorithm can change at any time" and "links must never break."