Tech With Galvan
Back to Articles
Development6/2/2025 • 3 min read

URL Shortener using Python and Streamlit

Build your own URL shortener with Python and Streamlit — generate short codes, track clicks, and manage your link library locally.

Galvan
Galvan

Founder & Creator

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

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). Use os.replace atomic writes: dump to links.tmp, then replace.
  • Shortened URL rejected — the scheme check requires http(s)://; auto-prepend https:// 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.

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.