Introduction
URL shorteners look like magic and are actually a dictionary: short code in, long URL out. This app builds your own private one — paste a link, get a short code, and keep a click-counted library of every link you have shortened. Unlike bit.ly, your data never leaves your machine, and the mapping is a JSON file you fully control.
It is the state-and-persistence lesson of the to-do list app applied to links, with a dash of collision-free ID generation from the password generator.
Features
- Shorten any URL — with optional custom short codes.
- Collision-free IDs — 6-character base62 codes.
- Click tracking — every redirect increments a counter.
- Link library — searchable table of code → URL → clicks.
- QR code bonus — every short link gets a scannable QR via the QR generator pattern.
Prerequisites
- Python 3.8+ — from python.org.
- Dependencies:
pip install streamlit qrcode[pil] requests
Step 1: Create the Script
Save as shortener.py:
import streamlit as st
import json
import os
import random
import string
import requests
DB = "links.json"
ALPHABET = string.ascii_letters + string.digits
def load_links():
if os.path.exists(DB):
return json.load(open(DB))
return {}
def save_links(links):
json.dump(links, open(DB, "w"), indent=2)
def new_code(links, length=6):
while True:
code = "".join(random.SystemRandom().choice(ALPHABET) for _ in range(length))
if code not in links:
return code
st.set_page_config(page_title="URL Shortener", page_icon="🔗")
st.title("🔗 URL Shortener")
links = load_links()
long_url = st.text_input("Long URL", placeholder="https://example.com/very/long/path")
custom = st.text_input("Custom code (optional)", placeholder="my-link")
if st.button("✂️ Shorten", type="primary") and long_url.strip():
if not long_url.startswith(("http://", "https://")):
st.error("URL must start with http:// or https://")
elif custom and custom in links:
st.warning("That code is taken!")
else:
code = custom.strip() or new_code(links)
links[code] = {"url": long_url.strip(), "clicks": 0}
save_links(links)
st.success(f"Short code: {code}")
st.divider()
st.subheader("Your links")
if links:
query = st.text_input("Search", placeholder="Filter by code or URL...")
for code, info in sorted(links.items(), key=lambda kv: -kv[1]["clicks"]):
if query.lower() in code.lower() or query.lower() in info["url"].lower():
c1, c2, c3 = st.columns([0.2, 0.55, 0.25])
c1.markdown(f"**/{code}**")
c2.markdown(f"[{info['url'][:60]}]({info['url']})")
c3.caption(f"{info['clicks']} clicks")
else:
st.info("No links yet.")
Step 2: Run the App
streamlit run shortener.py
Shorten a few links, then test resolution — see the redirect section below for the tiny Flask server that turns /code into the destination.
How It Works
The storage model is a JSON dictionary: code → {url, clicks}. Generating a code is generate-and-check: draw 6 random base62 characters (62⁶ ≈ 56 billion combinations) and retry on the astronomically unlikely collision. Custom codes skip generation but get a taken-check — the same defensive validation the expense tracker applies to form input.
A shortener is only half-complete without resolution. Streamlit apps are single-page, so actual redirect duty falls to a ~10-line Flask companion:
from flask import Flask, redirect
import json
app = Flask(__name__)
@app.route("/<code>")
def resolve(code):
links = json.load(open("links.json"))
if code in links:
links[code]["clicks"] += 1
json.dump(links, open("links.json", "w"), indent=2)
return redirect(links[code]["url"])
return "Unknown link", 404
app.run(port=5000)
Run both processes side by side: Streamlit manages links, Flask resolves them at localhost:5000/code.
Common Errors & Fixes
json.decoder.JSONDecodeError— the file was corrupted by concurrent writes (Streamlit + Flask both writing). Useos.replaceatomic writes: dump tolinks.tmp, then replace.- Shortened URL rejected — the scheme check requires
http(s)://; auto-prependhttps://in the handler if you prefer forgiving input. - Clicks not incrementing — Flask reads the file before Streamlit’s last save; both processes should open-write-close quickly, and ideally share a single loader function.
- Custom code with spaces/symbols — sanitize with a regex
^[A-Za-z0-9_-]+$before accepting.
Key Concepts
- Shortener = dictionary — code-to-URL mapping is the entire product.
- Base62 IDs — URL-safe, compact, collision-checked.
- Two-process design — management UI and redirect service as separate processes sharing one file.
- Click analytics — a counter field is all “analytics” ever needed to start.
What to Try Next
- Move storage to SQLite so both processes share one safe database.
- Add expiry dates per link and prune expired codes on load.
- Log click timestamps to chart traffic over time with the dashboard patterns.
- Deploy Flask on your home server with a real domain for public short links.
FAQ
Can this replace bit.ly?
For personal use, better — no rate limits, full data ownership. For public use you need a domain, a always-on host, and abuse monitoring.
Why 6 characters?
56 billion combinations covers any personal library with collision odds near zero. Bump to 7–8 if you are ambitious; each character multiplies the space by 62.
Do QR codes work with short links?
Perfectly — that is their classic pairing. Generate a QR of localhost:5000/code (or your domain) and the short link becomes scannable, with click tracking intact.