A fast and simple URL shortener with a dark-themed web UI, custom aliases, and click tracking.
- Shorten URLs with a single click
- Custom aliases like
/my-link - Click tracking with last-clicked timestamp
- Dark-themed responsive UI
- REST API for programmatic access
- QR code generation for any link
- Link preview to see a destination without counting a click
- Link expiration via
ttl_hours - Permanent (308) redirects opt-in, temporary (307) by default
- Reserved aliases block route collisions
- URL validation allows only
http://andhttps://schemes - Async SQLite (aiosqlite)
# Clone the repository
git clone https://github.com/qorexdevs/url-shortener.git
cd url-shortener
# Create virtual environment
python -m venv venv
source venv/bin/activate # Linux/macOS
venv\Scripts\activate # Windows
# Install dependencies
pip install -r requirements.txt
# Run the server
uvicorn app.main:app --reloadOpen http://localhost:8000 in your browser.
docker compose up -dThe database is stored in a named volume so your data persists across container restarts.
POST /api/shorten
Content-Type: application/jsonOnly http:// and https:// URLs are accepted, including localhost and loopback IP addresses (127.0.0.1, ::1). URLs longer than 2048 characters are rejected.
{
"url": "https://example.com/very/long/url",
"custom_alias": "my-link",
"ttl_hours": 24,
"permanent": false
}Response:
{
"original_url": "https://example.com/very/long/url",
"short_url": "http://localhost:8000/my-link",
"short_code": "my-link",
"expires_at": "2025-01-02T00:00:00",
"permanent": false
}expires_at is null when no ttl_hours was set. Set permanent: true to redirect with a
cacheable 308 instead of the default 307. Browsers cache it, so later hits skip the server and
stop counting clicks.
GET /api/stats/{code}{
"original_url": "https://example.com/very/long/url",
"short_url": "http://localhost:8000/my-link",
"short_code": "my-link",
"clicks": 42,
"created_at": "2025-01-01T00:00:00",
"last_clicked": "2025-01-02T12:30:00",
"expires_at": "2025-01-02T00:00:00",
"expired": false
}GET /api/links -> newest first, 50 per page
GET /api/links?limit=20&offset=40 -> page through them
GET /api/links?sort=clicks -> most clicked first
GET /api/links?sort=recent -> most recently clicked first, never-clicked last
GET /api/links?sort=stale -> least recently clicked first, never-clicked on top
GET /api/links?sort=expiring -> soonest to expire first, no-ttl links last
GET /api/links?status=active -> only links that haven't expired
GET /api/links?status=expired -> only links past their ttl
GET /api/links?status=permanent -> only permanent 308 links
GET /api/links?q=github -> match the destination url, code or alias
GET /api/links?min_clicks=10 -> only links with at least that many clicks
GET /api/links?max_clicks=1 -> only links with at most that many clicks
GET /api/links?created_after=2026-01-01 -> only links created on/after a date
GET /api/links?created_before=2026-06-01 -> only links created on/before a date
GET /api/links?clicked_after=2026-01-01 -> only links last clicked on/after a date
GET /api/links?clicked_before=2026-06-01 -> only links last clicked on/before a date
GET /api/links.csv -> download every matching link as csv
GET /api/links.csv?status=active&q=github -> same status and q filterscreated_after and created_before take an ISO date or datetime (a tz-aware value is converted to UTC) and pair into a window, so created_before=2026-01-01 finds old links to prune. clicked_after and clicked_before work the same way against last_clicked, so clicked_before=2026-01-01 surfaces links nobody has followed in a while - never-clicked links have no click date and drop out of either bound.
/api/links.csv dumps every link matching the same status, q, min_clicks, max_clicks, created_after/created_before and clicked_after/clicked_before filters as a csv (no paging), with a Content-Disposition so a browser downloads it - handy for a backup or a spreadsheet.
Returns every link with the same fields as stats, newest first. limit is 1-100 (default 50) and offset skips that many rows, so offset=limit gets the next page. The X-Total-Count header carries how many links match the current status and q filters, so you can size the pager without fetching every page. sort is created (default), clicks for the most clicked first, recent for the most recently clicked first with never-clicked links last, stale for the least recently clicked first with never-clicked links on top (handy for pruning dead links), or expiring for the soonest to expire first with no-ttl links last. status is all (default), active, or expired. min_clicks keeps only links with at least that many clicks (default 0, so everything) and max_clicks keeps only links with at most that many (default unbounded), so min_clicks=1&max_clicks=1 is an exact range and max_clicks=0 finds never-clicked links to prune. 400 on a bad limit, a negative offset, an unknown sort, an unknown status, a negative min_clicks, or a negative max_clicks.
GET /api/preview/{code}{
"short_url": "http://localhost:8000/my-link",
"original_url": "https://example.com/very/long/url",
"expires_at": "2025-01-02T00:00:00",
"expired": false
}Resolves where a short link points without following it. No click is counted and there is no redirect, so it is safe for checking a link before opening it. You can also append + to the short link itself (GET /{code}+) to get the same preview, the way bitly does.
GET /api/qr/{code} -> PNG image
GET /api/qr/{code}?fmt=svg -> SVG image
GET /api/qr/{code}?scale=20&border=2 -> bigger image, tighter quiet zoneReturns a QR code image encoding the short URL. Useful for sharing links in print or presentations.
PNG by default; pass fmt=svg for a crisp, scalable vector you can drop into print or the web.
scale sets the pixel size of each module (1-40, default 10) and border the quiet zone width (0-20, default 4).
PATCH /api/links/{code}
{ "url": "https://example.org/new", "ttl_hours": 24 }Updates an existing link in place, keeping its code, alias and click count. Send url to point it somewhere new, ttl_hours to reset the expiry window from now, or both. At least one is required. The URL is validated and normalized like on shorten, and ttl_hours follows the same bounds. Returns the updated stats, 400 on a bad URL, bad ttl or an empty body, 404 if nothing matches.
DELETE /api/links/{code} -> 204 No ContentRemoves a short link by its code or custom alias. Returns 404 if nothing matches. The code lookup is case-insensitive, same as the other endpoints.
DELETE /api/expired -> { "deleted": 3 }Drops every link that's past its ttl in one pass and returns how many were removed. Links without a ttl are left alone. Handy for a cron job or a manual cleanup so expired rows don't pile up.
GET /{code} -> 307 redirect to original URL (308 for a permanent link)
GET /{code}+ -> preview the destination instead of following it| Variable | Default | Description |
|---|---|---|
BASE_URL |
http://localhost:8000 |
Base URL for generated short links |
DATABASE_URL |
sqlite+aiosqlite:///./shortener.db |
Database connection string |
url-shortener/
|-- app/
| |-- __init__.py
| |-- main.py # FastAPI application entry point
| |-- config.py # Settings and configuration
| |-- database.py # Async engine and session
| |-- models.py # SQLAlchemy URL model
| |-- schemas.py # Pydantic schemas
| |-- utils.py # Short code generation
| |-- routers/
| | |-- __init__.py
| | |-- api.py # REST API endpoints
| | `-- pages.py # Web UI routes
| |-- templates/
| | |-- base.html # Base layout
| | |-- index.html # Main page (shorten form)
| | `-- stats.html # Link statistics page
| `-- static/
| |-- css/style.css # Dark theme styles
| `-- js/main.js # Frontend logic
|-- Dockerfile
|-- docker-compose.yml
|-- requirements.txt
|-- .gitignore
`-- README.md
| Component | Technology |
|---|---|
| Framework | FastAPI 0.115 |
| ORM | SQLAlchemy 2.0 (async) |
| Database | SQLite via aiosqlite |
| Templates | Jinja2 |
| Frontend | Vanilla HTML/CSS/JS |
| Server | Uvicorn |
MIT