Skip to content
Build a URL Shortener (Like bit.ly) — Full-Stack Tutorial

Build a URL Shortener (Like bit.ly) — Full-Stack Tutorial

DodaTech Updated Jun 19, 2026 10 min read

Build a full-stack URL shortener with Python Flask that generates short hashes, tracks click analytics, stores data in SQLite, and provides a clean web interface for creating and managing shortened links.

What You’ll Build

You’ll build shorturl, a web application where users paste a long URL, get back a short code like https://shorturl/abc123, and can view click analytics (total clicks, last clicked, referrer data). The same redirection logic is used at DodaTech in DodaZIP’s shareable download links and Doda Browser’s bookmark sync service.

Why Build a URL Shortener?

URL shorteners solve a simple problem: long URLs are ugly, hard to share, and break in SMS messages. Beyond that, they provide analytics — knowing how many people clicked a link and where they came from is valuable for marketing campaigns, social media sharing, and tracking. It’s also a perfect full-stack project: hashing, database, redirect logic, and a frontend in one small app.

Prerequisites

Step 1: Setup

mkdir url-shortener
cd url-shortener
python -m venv venv
source venv/bin/activate
pip install flask python-dotenv

Project structure:

url-shortener/
├── app.py
├── models.py
├── templates/
│   ├── index.html
│   └── stats.html
└── .env

Step 2: Database Model

# models.py
import sqlite3
from contextlib import contextmanager
from datetime import datetime

DATABASE = "shorturl.db"

@contextmanager
def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
    finally:
        conn.close()

def init_db():
    with get_db() as db:
        db.executescript("""
            CREATE TABLE IF NOT EXISTS urls (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                short_code TEXT UNIQUE NOT NULL,
                original_url TEXT NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                clicks INTEGER DEFAULT 0
            );
            CREATE TABLE IF NOT EXISTS clicks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                short_code TEXT NOT NULL,
                clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                referrer TEXT DEFAULT '',
                user_agent TEXT DEFAULT '',
                ip_address TEXT DEFAULT '',
                FOREIGN KEY (short_code) REFERENCES urls(short_code)
            );
            CREATE INDEX IF NOT EXISTS idx_short_code ON urls(short_code);
        """)
        db.commit()

Two tables: urls stores the mapping (short code → original URL), and clicks logs every visit with metadata. Separating them means you can run analytics queries without slowing down redirects.

Step 3: Hash Generation

# hash_utils.py
import secrets
import string

def generate_short_code(length: int = 6) -> str:
    """Generate a random alphanumeric short code."""
    alphabet = string.ascii_letters + string.digits
    return ''.join(secrets.choice(alphabet) for _ in range(length))

def is_valid_url(url: str) -> bool:
    """Basic URL validation."""
    return url.startswith(("http://", "https://"))

secrets.choice() is cryptographically secure — this matters because predictable short codes could let someone enumerate all your shortened URLs. We use 6 characters from a 62-character alphabet, giving us 62⁶ ≈ 56 billion possible combinations.

Step 4: The Flask App

# app.py
from flask import Flask, request, redirect, render_template, jsonify, abort
from models import init_db, get_db
from hash_utils import generate_short_code, is_valid_url
import os
from dotenv import load_dotenv

load_dotenv()
app = Flask(__name__)
app.config["BASE_URL"] = os.getenv("BASE_URL", "http://localhost:5000")

@app.before_request
def initialize():
    init_db()

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/shorten", methods=["POST"])
def shorten():
    original_url = request.form.get("url", "").strip()
    if not original_url:
        return jsonify({"error": "URL is required"}), 400
    if not is_valid_url(original_url):
        return jsonify({"error": "URL must start with http:// or https://"}), 400

    # Check for existing short code for this URL
    with get_db() as db:
        existing = db.execute(
            "SELECT short_code FROM urls WHERE original_url = ?", (original_url,)
        ).fetchone()
        if existing:
            short_code = existing["short_code"]
        else:
            short_code = generate_short_code()
            # Ensure uniqueness (extremely unlikely collision)
            while db.execute(
                "SELECT id FROM urls WHERE short_code = ?", (short_code,)
            ).fetchone():
                short_code = generate_short_code()

            db.execute(
                "INSERT INTO urls (short_code, original_url) VALUES (?, ?)",
                (short_code, original_url)
            )
            db.commit()

    short_url = f"{app.config['BASE_URL']}/{short_code}"
    return jsonify({"short_url": short_url, "short_code": short_code})

@app.route("/<short_code>")
def redirect_to_url(short_code):
    """Redirect short code to original URL and log the click."""
    with get_db() as db:
        row = db.execute(
            "SELECT original_url FROM urls WHERE short_code = ?", (short_code,)
        ).fetchone()

        if not row:
            abort(404)

        original_url = row["original_url"]

        # Log the click
        db.execute(
            """INSERT INTO clicks (short_code, referrer, user_agent, ip_address)
               VALUES (?, ?, ?, ?)""",
            (
                short_code,
                request.referrer or "",
                request.user_agent.string or "",
                request.remote_addr or ""
            )
        )
        db.execute(
            "UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?",
            (short_code,)
        )
        db.commit()

    return redirect(original_url, code=302)

@app.route("/stats/<short_code>")
def stats(short_code):
    """View click analytics for a short code."""
    with get_db() as db:
        url_info = db.execute(
            "SELECT * FROM urls WHERE short_code = ?", (short_code,)
        ).fetchone()
        if not url_info:
            abort(404)

        click_data = db.execute(
            """SELECT * FROM clicks WHERE short_code = ?
               ORDER BY clicked_at DESC LIMIT 100""",
            (short_code,)
        ).fetchall()

    return render_template("stats.html", url=dict(url_info), clicks=[dict(c) for c in click_data])

@app.route("/api/stats/<short_code>")
def api_stats(short_code):
    """JSON API for click analytics."""
    with get_db() as db:
        url_info = db.execute(
            "SELECT * FROM urls WHERE short_code = ?", (short_code,)
        ).fetchone()
        if not url_info:
            return jsonify({"error": "Not found"}), 404

        return jsonify({
            "short_code": url_info["short_code"],
            "original_url": url_info["original_url"],
            "clicks": url_info["clicks"],
            "created_at": url_info["created_at"]
        })

if __name__ == "__main__":
    app.run(debug=True, port=5000)

Step 5: Frontend Templates

<!-- templates/index.html -->
<!DOCTYPE html>
<html>
<head>
    <title>URL Shortener</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
        .card { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); width: 500px; }
        h1 { margin-bottom: 8px; }
        .subtitle { color: #666; margin-bottom: 24px; }
        .input-group { display: flex; gap: 8px; }
        input[type="text"] { flex: 1; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; }
        input[type="text"]:focus { border-color: #007bff; outline: none; }
        button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
        button:hover { background: #0056b3; }
        .result { margin-top: 20px; padding: 16px; background: #e8f5e9; border-radius: 8px; display: none; }
        .result a { color: #2e7d32; font-weight: bold; word-break: break-all; }
        .error { margin-top: 12px; color: #d32f2f; display: none; }
        .stats-link { margin-top: 8px; font-size: 14px; }
    </style>
</head>
<body>
    <div class="card">
        <h1>ShortURL</h1>
        <p class="subtitle">Paste a long URL and make it short</p>
        <div class="input-group">
            <input type="text" id="urlInput" placeholder="https://example.com/very/long/url" autofocus>
            <button onclick="shorten()">Shorten</button>
        </div>
        <div id="result" class="result">
            <p>Short URL: <a id="shortUrl" href="#" target="_blank"></a></p>
            <p class="stats-link"><a id="statsLink" href="#">View analytics →</a></p>
        </div>
        <div id="error" class="error"></div>
    </div>

    <script>
        async function shorten() {
            const url = document.getElementById('urlInput').value.trim();
            if (!url) return;

            const result = document.getElementById('result');
            const error = document.getElementById('error');
            result.style.display = 'none';
            error.style.display = 'none';

            try {
                const response = await fetch('/shorten', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/x-www-form-urlencoded'},
                    body: 'url=' + encodeURIComponent(url)
                });
                const data = await response.json();

                if (response.ok) {
                    document.getElementById('shortUrl').textContent = data.short_url;
                    document.getElementById('shortUrl').href = data.short_url;
                    document.getElementById('statsLink').href = '/stats/' + data.short_code;
                    result.style.display = 'block';
                } else {
                    error.textContent = data.error;
                    error.style.display = 'block';
                }
            } catch (e) {
                error.textContent = 'Network error — is the server running?';
                error.style.display = 'block';
            }
        }

        document.getElementById('urlInput').addEventListener('keydown', function(e) {
            if (e.key === 'Enter') shorten();
        });
    </script>
</body>
</html>
<!-- templates/stats.html -->
<!DOCTYPE html>
<html>
<head>
    <title>Stats — {{ url.short_code }}</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: system-ui, sans-serif; background: #f5f5f5; padding: 40px; }
        .card { background: white; padding: 32px; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 700px; margin: 0 auto; }
        h1 { margin-bottom: 20px; }
        .stat-row { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #eee; }
        .stat-label { color: #666; }
        .stat-value { font-weight: bold; }
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
        th { color: #666; font-weight: 600; }
        .back { display: inline-block; margin-bottom: 20px; color: #007bff; text-decoration: none; }
    </style>
</head>
<body>
    <div class="card">
        <a href="/" class="back">← Create another</a>
        <h1>Analytics for <code>{{ url.short_code }}</code></h1>

        <div class="stat-row">
            <span class="stat-label">Original URL</span>
            <span class="stat-value"><a href="{{ url.original_url }}" target="_blank">{{ url.original_url[:60] }}...</a></span>
        </div>
        <div class="stat-row">
            <span class="stat-label">Total Clicks</span>
            <span class="stat-value">{{ url.clicks }}</span>
        </div>
        <div class="stat-row">
            <span class="stat-label">Created</span>
            <span class="stat-value">{{ url.created_at }}</span>
        </div>

        <h2 style="margin-top: 28px;">Recent Clicks</h2>
        {% if clicks %}
        <table>
            <tr><th>Time</th><th>Referrer</th><th>IP Address</th></tr>
            {% for click in clicks %}
            <tr>
                <td>{{ click.clicked_at }}</td>
                <td>{{ click.referrer[:30] if click.referrer else 'Direct' }}</td>
                <td>{{ click.ip_address }}</td>
            </tr>
            {% endfor %}
        </table>
        {% else %}
        <p style="color: #888; margin-top: 12px;">No clicks yet. Share your short URL to see analytics here.</p>
        {% endif %}
    </div>
</body>
</html>

Step 6: Run the App

python app.py

Expected output:

 * Running on http://127.0.0.1:5000

Open http://localhost:5000. Paste https://www.example.com/some/very/long/path/that/needs/shortening. Click “Shorten”. You’ll see http://localhost:5000/abc123. Open that in a new tab — it redirects to your original URL. Visit /stats/abc123 to see the click count.

Test with curl:

curl http://localhost:5000/shorten -d "url=https://google.com"
# {"short_url":"http://localhost:5000/Xk7mN2","short_code":"Xk7mN2"}

curl -v http://localhost:5000/Xk7mN2
# HTTP/1.1 302 FOUND
# Location: https://google.com

Architecture


flowchart LR
    A[User] -->|1. Paste long URL| B[Web Interface]
    B -->|2. POST /shorten| C[Flask Server]
    C -->|3. Generate hash| D[hash_utils.py]
    C -->|4. Store mapping| E[(SQLite)]
    C -->|5. Return short URL| B
    B -->|6. Display| A

    A -->|7. Visit short URL| F[Browser]
    F -->|8. GET /abc123| C
    C -->|9. Lookup in DB| E
    C -->|10. Log click| E
    C -->|11. Redirect 302| F
    F -->|12. Original URL| G[Target Site]

Common Errors

1. “SQLite objects created in a thread can only be used in that same thread” Flask’s debug mode uses the reloader, which spawns multiple threads. Use app.run(debug=False, threaded=False) during development, or configure SQLite for thread-safe access with check_same_thread=False in the connection.

2. Short code collisions With 6-character random codes, collisions are extremely rare but not impossible. Our code checks for duplicates before inserting, but a race condition could occur between the check and insert. For production, catch the UNIQUE constraint violation and retry with a new code.

3. 404 on redirect The user might have mistyped the short code. Our app returns a 404 page for invalid codes. Consider showing a branded 404 with a “Create your own short link” form to retain the user.

4. Infinite redirect loops If someone shortens a URL that redirects back to your shortener, you get an infinite loop. Validate that the target URL is different from your base URL. Or set a maximum redirect depth in your analytics.

Practice Questions

1. Why do we use secrets instead of random for hash generation? secrets.choice() is cryptographically secure — it uses the OS’s entropy source. random.choice() is predictable (it’s a PRNG seeded by time), which would allow attackers to enumerate all short codes and spy on your users’ links.

2. What’s the difference between 301 and 302 redirects? 301 is “Moved Permanently” — browsers cache it, so clicking the short URL again goes directly to the target without hitting your server. 302 is “Found” (temporary) — every click goes through your server, allowing you to count it. For analytics, use 302. For permanent shortening services, some use 301 after the first click.

3. How would you handle custom short codes? Add an optional custom parameter to shorten(). If provided, use it instead of a random code. Check for uniqueness and validate it contains only alphanumeric characters. Store it in the same column but add a length constraint.

4. Challenge: Add expiration dates Add an expires_at column to the urls table. Create a background cleanup script that deletes expired URLs and their click logs. The redirect endpoint should check expiration and return a “This link has expired” page instead of redirecting.

5. Challenge: Rate limiting for URL creation Limit users to 10 short URLs per minute. Use a fixed-window counter stored in memory or Redis. Return 429 Too Many Requests when exceeded. Combine with the client IP to prevent one user from overwhelming the system.

FAQ

How do I prevent someone from guessing short codes?
Use longer codes (8+ characters), use secrets module, and implement rate limiting on redirect requests. For sensitive links, add an optional password: store password_hash in the DB and require it as a query parameter to redirect.
Can I use this with a custom domain?
Yes. Set BASE_URL=https://your.domain.com in .env. Configure your DNS to point the domain to your server. For a bit.ly-like experience, use A records for the apex domain or CNAME for a subdomain.
How do I add user accounts?
Add a users table and link urls.user_id to it. Use Flask-Login for session management. Allow users to view all their short URLs on a dashboard. For authentication, see the Auth0 or JWT tutorial.

Next Steps

  • Deploy with Docker and Docker Compose
  • Switch to PostgreSQL for production scaling
  • Explore Redis caching for frequently accessed URLs
  • Build the REST API tutorial to add a full API layer to your shortener

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro