DEV Community

Cover image for My Experience Building a URL Shortener Like TinyURL
Developer Service
Developer Service

Posted on • Originally published at developer-service.blog

My Experience Building a URL Shortener Like TinyURL

You know that moment when you're sharing a link to your latest blog post, and the URL looks like https://developer-service.blog/build-your-own-low-cost-cloud-backup-with-hetzner-storage-boxes/?

Yeah, that's not going to fit nicely anywhere.

I faced this problem constantly while managing my blog, sharing free guides, and tracking GitHub repository statistics across different platforms.

Twitter's character limits, newsletter formatting issues, clean QR codes for printed materials, every platform seemed to hate my long, descriptive URLs.

Sure, I could use bit.ly or TinyURL, but then I'd be giving up control over my links, paying for premium features, and most importantly, losing valuable analytics data.

So I did what any self-respecting developer would do: I spent a weekend building my own URL shortener. And you know what? It was way more interesting than I expected.

Full source code link at the end of the article


What Exactly Is a URL Shortener?

At its core, a URL shortener is beautifully simple: it takes a long URL and maps it to a short, memorable code. When someone visits the short URL, they're redirected to the original destination.

Long URL:  https://developer-service.blog/build-your-own-low-cost-cloud-backup-with-hetzner-storage-boxes/
           ↓
Short URL: https://myurl.app/0001ab
           ↓
Redirect:  302 Found → Original URL
Enter fullscreen mode Exit fullscreen mode

But the devil's in the details. A production-ready URL shortener needs to handle:

  • Unique short codes without collisions
  • Analytics tracking for every click
  • Custom slugs for branded links
  • Expiration dates for temporary campaigns
  • Security against abuse and malicious URLs

The Technical Challenge: URL Slug Collisions and Uniqueness

This was the most interesting problem to solve. How do you generate short, unique identifiers that won't collide as your database grows?

The Naive Approach (Don't Do This)

My first instinct was to use random strings:

import random
import string

def generate_slug():
    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
Enter fullscreen mode Exit fullscreen mode

The problem? With a 6-character alphanumeric string (36^6 = 2.1 billion possibilities), you'd think collisions are rare. But thanks to the quirky birthday paradox, you'll hit your first collision around 55,000 URLs, which is a bit sooner than expected.

The Better Approach: Base62 Encoding with Auto-Increment IDs

Instead, I went with a deterministic approach using database auto-increment IDs:

import string
from typing import Optional

BASE62 = string.digits + string.ascii_lowercase + string.ascii_uppercase

def encode_id(num: int, min_length: int = 6) -> str:
    """
    Convert a number to a base62 string, padded to minimum length.
    With 6 characters and base62, you can encode up to 56.8 billion URLs.
    """
    if num == 0:
        return BASE62[0].zfill(min_length)

    result = []
    while num:
        result.append(BASE62[num % 62])
        num //= 62

    encoded = ''.join(reversed(result))

    # Pad with leading zeros to meet minimum length
    if len(encoded) < min_length:
        encoded = BASE62[0] * (min_length - len(encoded)) + encoded

    return encoded

def decode_slug(slug: str) -> Optional[int]:
    """Convert a base62 string back to a number."""
    try:
        num = 0
        for char in slug:
            num = num * 62 + BASE62.index(char)
        return num
    except ValueError:
        # Character not in BASE62 alphabet
        return None
Enter fullscreen mode Exit fullscreen mode

This approach guarantees:

  • Zero collisions: Each ID maps to exactly one slug
  • Consistent length: All slugs are 6 characters (e.g., "000001", "000abc")
  • Predictable growth: You know exactly how many URLs each slug length supports
  • Reversibility: You can decode slugs back to IDs for quick lookup

The math is beautiful:

  • 6 characters (default): 56.8 billion possible URLs
  • First URL: 000001 (ID: 1)
  • 100th URL: 0001Cv (ID: 100)
  • Millionth URL: 004C92 (ID: 1,000,000)

By padding to 6 characters, all URLs have a consistent, professional look. When you eventually hit 6 characters of actual data, they're already quite short anyway!

Custom Slugs: The Best of Both Worlds

Of course, sometimes you want a branded slug like myurl.app/blog-launch instead of myurl.app/000x7k. I added support for custom slugs with validation and uniqueness checks:

RESERVED_SLUGS = {"stats", "shorten", "admin", "login", "logout"}

def validate_custom_slug(slug: str) -> bool:
    """Validate custom slug format."""
    if not slug or len(slug) < 3 or len(slug) > 50:
        return False

    allowed_chars = set(string.ascii_letters + string.digits + '-_')
    return all(char in allowed_chars for char in slug)

# In the /shorten route:
if custom_slug:
    custom_slug = custom_slug.strip().lower()  # Normalize to lowercase

    # Check if slug is reserved
    if custom_slug in RESERVED_SLUGS:
        raise HTTPException(status_code=400, detail="Slug is reserved")

    # Validate format
    if not validate_custom_slug(custom_slug):
        raise HTTPException(status_code=400, detail="Invalid slug format")

    # Check if already taken
    existing = db.query(URL).filter(URL.slug == custom_slug).first()
    if existing:
        raise HTTPException(status_code=400, detail="Slug already taken")

    new_url = URL(slug=custom_slug, long_url=long_url)
else:
    # Auto-generate from ID
    new_url = URL(long_url=long_url, slug="temp")
    db.add(new_url)
    db.flush()
    new_url.slug = encode_id(new_url.id, min_length=6)
Enter fullscreen mode Exit fullscreen mode

Custom slugs are validated to ensure they only contain alphanumeric characters, hyphens, and underscores (3-50 characters). Reserved slugs like "stats" and "admin" are blocked to avoid conflicts with routes.


Analytics and Dashboard

This is where building your own URL shortener really shines. Every major platform (Twitter, LinkedIn, newsletters, GitHub README) has different traffic patterns, and I wanted to see them all in one place.

What I Track

For every click, I capture:

class Click(Base):
    __tablename__ = "clicks"

    id = Column(Integer, primary_key=True, index=True)
    url_id = Column(Integer, ForeignKey("urls.id"), nullable=False, index=True)
    timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)

    # Request metadata
    referrer = Column(String, nullable=True)
    user_agent = Column(String, nullable=True)
    ip_address = Column(String, nullable=True)

    # Geographic data (would require GeoIP library)
    country = Column(String, nullable=True)
    city = Column(String, nullable=True)

    # Device information (parsed from user agent)
    device_type = Column(String, nullable=True)  # mobile, desktop, tablet, bot
    browser = Column(String, nullable=True)
    os = Column(String, nullable=True)

    url = relationship("URL", back_populates="clicks")
Enter fullscreen mode Exit fullscreen mode

The Dashboard

I built a simple dashboard using Bootstrap 5 that shows:

  1. Total clicks with time-series line chart
  2. Unique visitors tracked by IP address
  3. Device type breakdown (mobile, desktop, tablet, bot) as a pie chart
  4. Top referrers with percentage bars (Twitter, LinkedIn, Direct, etc.)
  5. Average clicks per day since creation
  6. Recent clicks table showing timestamp, referrer, device, browser, OS, and IP

The most surprising insight? My GitHub repository links get significantly more clicks from mobile devices than desktop. I never would have guessed that people browse code repos on their phones so much!

Note: Geographic distribution (countries/cities) could be added using a GeoIP library like geoip2, but I kept it simple to avoid external dependencies and API limits.


Building it with FastAPI, Bootstrap 5, and Jinja2

Let me walk you through the actual implementation. I chose this stack because:

  • FastAPI: Lightning-fast, automatic API docs, async support
  • Bootstrap 5: Quick, responsive UI without wrestling with CSS
  • Jinja2: Server-side rendering for SEO and simplicity
  • SQLite: Zero-config database perfect for side projects

The Database Schema

Simple SQLAlchemy models with a one-to-many relationship:

from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime, timezone

class URL(Base):
    __tablename__ = "urls"

    id = Column(Integer, primary_key=True, autoincrement=True, index=True)
    slug = Column(String, unique=True, index=True, nullable=False)
    long_url = Column(String, nullable=False)
    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
    expires_at = Column(DateTime, nullable=True)

    clicks = relationship("Click", back_populates="url", cascade="all, delete-orphan")

class Click(Base):
    __tablename__ = "clicks"

    id = Column(Integer, primary_key=True, index=True)
    url_id = Column(Integer, ForeignKey("urls.id"), nullable=False, index=True)
    timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)

    # Request metadata
    referrer = Column(String, nullable=True)
    user_agent = Column(String, nullable=True)
    ip_address = Column(String, nullable=True)

    # Device information (parsed from user agent)
    device_type = Column(String, nullable=True)
    browser = Column(String, nullable=True)
    os = Column(String, nullable=True)

    url = relationship("URL", back_populates="clicks")
Enter fullscreen mode Exit fullscreen mode

The URL model stores the shortened links, while Click tracks every redirect for analytics. Using datetime.now(timezone.utc) ensures timezone-aware timestamps. The cascade option means deleting a URL also removes all its clicks.

The FastAPI Application

Here are the key routes (see full implementation on GitHub):

from fastapi import FastAPI, HTTPException, Request, Depends, Form
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from typing import Optional

app = FastAPI(title="TinyURL Clone")

@app.post("/shorten")
async def create_short_url(
    request: Request,
    long_url: str = Form(...),
    custom_slug: Optional[str] = Form(None),
    db: Session = Depends(get_db)
):
    """Create a new short URL"""
    # Validate URL format
    if not validate_url(long_url):
        raise HTTPException(status_code=400, detail="Invalid URL format")

    if custom_slug:
        custom_slug = custom_slug.strip().lower()

        # Validate custom slug format
        if not validate_custom_slug(custom_slug):
            raise HTTPException(status_code=400, detail="Invalid slug format")

        # Check if already taken
        existing = db.query(URL).filter(URL.slug == custom_slug).first()
        if existing:
            raise HTTPException(status_code=400, detail="Slug already taken")

        new_url = URL(slug=custom_slug, long_url=long_url)
        db.add(new_url)
        db.commit()
    else:
        # Auto-generate from ID
        new_url = URL(long_url=long_url, slug="temp")
        db.add(new_url)
        db.flush()  # Get the auto-increment ID
        new_url.slug = encode_id(new_url.id)
        db.commit()

    base_url = str(request.base_url).rstrip('/')
    return {"short_url": f"{base_url}/{new_url.slug}"}

@app.get("/{slug}")
async def redirect_to_long_url(
    slug: str,
    request: Request,
    db: Session = Depends(get_db)
):
    """Redirect short URL to original URL and track analytics"""
    url = db.query(URL).filter(URL.slug == slug).first()

    if not url:
        raise HTTPException(status_code=404, detail="URL not found")

    # Check expiration
    if url.expires_at and datetime.now(timezone.utc) > url.expires_at:
        raise HTTPException(status_code=410, detail="URL has expired")

    # Parse user agent for device info
    device_info = parse_user_agent(request.headers.get("user-agent"))

    # Track the click
    click = Click(
        url_id=url.id,
        referrer=request.headers.get("referer"),
        user_agent=request.headers.get("user-agent"),
        ip_address=request.client.host if request.client else None,
        device_type=device_info["device_type"],
        browser=device_info["browser"],
        os=device_info["os"]
    )
    db.add(click)
    db.commit()

    return RedirectResponse(url=url.long_url, status_code=302)
Enter fullscreen mode Exit fullscreen mode

The application also includes routes for viewing stats (/stats/{slug}) and the homepage dashboard. Note the use of Form(...) for proper form data handling and the parse_user_agent() utility for extracting device information.

The Bootstrap 5 Dashboard Template

I built a clean, responsive UI using Bootstrap 5. Here's the key form structure:

<!-- templates/index.html - Main form (simplified) -->
<form id="shortenForm">
  <div class="mb-3">
    <label for="longUrl" class="form-label">Long URL</label>
    <input
      type="url"
      class="form-control form-control-lg"
      id="longUrl"
      placeholder="https://example.com/very/long/url"
      required
    />
  </div>

  <div class="mb-3">
    <label for="customSlug" class="form-label"> Custom Slug (Optional) </label>
    <input
      type="text"
      class="form-control"
      id="customSlug"
      placeholder="my-custom-link"
    />
  </div>

  <button type="submit" class="btn btn-primary btn-lg w-100">
    Shorten URL
  </button>
</form>

<script>
  document
    .getElementById("shortenForm")
    .addEventListener("submit", async (e) => {
      e.preventDefault();

      const formData = new URLSearchParams();
      formData.append("long_url", document.getElementById("longUrl").value);
      formData.append(
        "custom_slug",
        document.getElementById("customSlug").value,
      );

      const response = await fetch("/shorten", {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: formData,
      });

      const data = await response.json();
      // Display the short URL
      document.getElementById("shortUrl").value = data.short_url;
    });
</script>
Enter fullscreen mode Exit fullscreen mode

The stats dashboard (templates/stats.html) displays analytics cards showing total clicks, unique visitors, referrer breakdown, and device statistics using Chart.js for visualizations.

Full templates available in the GitHub repository.


SQLite: The Perfect Database for Side Projects

One of the best decisions I made was using SQLite. Here's why:

Zero Configuration

import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

# Can be overridden with environment variable
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./urls.db")

# SQLite-specific optimizations
connect_args = {
    "check_same_thread": False,
    "timeout": 30
}

engine = create_engine(
    DATABASE_URL,
    connect_args=connect_args,
    pool_pre_ping=True  # Handle stale connections
)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Enter fullscreen mode Exit fullscreen mode

The entire database is a single file (urls.db) that lives in your project directory. You can back it up, version control it, or deploy it anywhere. The pool_pre_ping option helps prevent stale connection errors.

Surprisingly Powerful

SQLite handles:

  • Transactions (ACID compliance)
  • Foreign keys
  • Indexes
  • Full-text search
  • JSON columns (SQLite 3.38+)

For a URL shortener with even millions of URLs, SQLite performs beautifully. It's only when you need high-concurrency writes (thousands per second) that you'd need PostgreSQL or MySQL.

Easy Backups

# Backup
sqlite3 urls.db ".backup urls_backup.db"

# Restore
cp urls_backup.db urls.db
Enter fullscreen mode Exit fullscreen mode

When to Upgrade

You should consider moving to PostgreSQL when:

  • You're getting >100 writes/second
  • You need multiple servers accessing the same database
  • You want advanced features like full-text search or geospatial queries
  • You're running in a distributed environment

For a personal URL shortener tracking blog posts and GitHub stats, SQLite is more than sufficient.


Deployment and Production Considerations

Running in Production

I deployed mine using Docker on a small VPS:

FROM python:3.11-slim

# Set working directory
WORKDIR /app

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application files
COPY . .

# Create directory for database
RUN mkdir -p /app/data

# Expose port
EXPOSE 8000

# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Enter fullscreen mode Exit fullscreen mode
# docker-compose.yml (simplified)
services:
  app:
    build: .
    container_name: tinyurl-clone
    ports:
      - "8000:8000"
    volumes:
      - ./data:/app/data
      - ./templates:/app/templates
    environment:
      - DATABASE_URL=sqlite:///./data/urls.db
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

The volume mounts ensure your database persists across container restarts, and mounting templates allows hot-reload during development.

Security Considerations

The current implementation includes basic validation (URL format checking and slug sanitization), but for production deployment, you should add:

  1. Rate Limiting: Use slowapi to limit requests (e.g., 10/minute per IP) to prevent abuse
   from slowapi import Limiter
   limiter = Limiter(key_func=get_remote_address)

   @app.post("/shorten")
   @limiter.limit("10/minute")
   async def create_short_url(...):
       # Your code here
Enter fullscreen mode Exit fullscreen mode
  1. Malicious URL Blocking: Maintain a blocklist of known malicious domains
   BLOCKED_DOMAINS = {"malware.com", "phishing.net"}

   def validate_url(url: str) -> bool:
       parsed = urlparse(url)
       if parsed.netloc in BLOCKED_DOMAINS:
           return False
Enter fullscreen mode Exit fullscreen mode
  1. HTTPS Only: Use Let's Encrypt with nginx reverse proxy in production

  2. Database Backups: Automate regular SQLite backups to prevent data loss

For a production system handling sensitive data, consider adding authentication, API keys, and more comprehensive logging.


Try It Out: Running the Demo

Want to see it in action? Getting started takes less than 5 minutes:

# Clone the repository
git clone https://github.com/nunombispo/building-my-own-tinyurl-article
cd building-my-own-tinyurl-article

# Install dependencies
pip install -r requirements.txt

# Run the application
uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

Then visit http://localhost:8000 in your browser. You'll see:

  1. Homepage: Clean form to shorten URLs with optional custom slugs
  2. Instant results: Copy button and links to visit or view stats
  3. Analytics dashboard: Visit /stats/{slug} to see click tracking in action

Try creating a few short URLs, clicking them from different devices, and watching the analytics populate in real-time. The SQLite database will be created automatically on first run.

Example of the homepage:

Homepage

Example of the analytics main information:

Analytics Dashboard

And referrers and recent clicks:

Top Referrers and Recent Clicks

Full source code at: https://github.com/nunombispo/building-my-own-tinyurl-article


Was It Worth It?

Absolutely. The total time investment was about 8 hours, and I now have:

  • Complete ownership of my links
  • Detailed analytics tailored to my needs
  • No monthly subscription fees
  • A fun weekend project that taught me about base62 encoding, collision handling, and FastAPI

Plus, I can share this with friends, add it to my portfolio, and know exactly how my content performs across different platforms.


Conclusion

Building a URL shortener taught me that sometimes the best tools are the ones you build yourself. Not because they're necessarily better than existing solutions, but because they're perfectly tailored to your needs.

For tracking blog posts, free guides, and GitHub statistics, having complete control over the analytics pipeline is invaluable. You own your data, customize the features you want, and learn something new in the process.

And honestly? It was just really fun to build.

If you're thinking about building your own, I say go for it. The technical challenges are interesting but manageable, and the end result is something you'll actually use every day. Start simple with the core features, then add complexity as you need it.

The complete source code is available on GitHub, so clone it, try it out, and make it your own!


Follow me on Twitter: https://twitter.com/DevAsService

Follow me on Instagram: https://www.instagram.com/devasservice/

Follow me on TikTok: https://www.tiktok.com/@devasservice

Follow me on YouTube: https://www.youtube.com/@DevAsService

Top comments (0)