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
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))
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
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)
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")
The Dashboard
I built a simple dashboard using Bootstrap 5 that shows:
- Total clicks with time-series line chart
- Unique visitors tracked by IP address
- Device type breakdown (mobile, desktop, tablet, bot) as a pie chart
- Top referrers with percentage bars (Twitter, LinkedIn, Direct, etc.)
- Average clicks per day since creation
- 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")
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)
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>
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)
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
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"]
# 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
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:
-
Rate Limiting: Use
slowapito 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
- 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
HTTPS Only: Use Let's Encrypt with nginx reverse proxy in production
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
Then visit http://localhost:8000 in your browser. You'll see:
- Homepage: Clean form to shorten URLs with optional custom slugs
- Instant results: Copy button and links to visit or view stats
-
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:
Example of the analytics main information:
And 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)