Paste a long link, get a short one — like bit.ly. Built end to end and deployed, so you can click it rather than take my word for it.
Try it: https://snip-u5ll.onrender.com
Three parts: a web page you type into, a server that stores the links and sends visitors on to the right place, and an API other programs can call. FastAPI and SQLite on the back, plain JavaScript on the front — no framework, no build step.
| Full-stack | Database, server, API and user interface, all written here and working together |
| Deployed and live | In a container on real hosting, not just running on my laptop |
| Security thinking | A link shortener will redirect anywhere you point it, which makes it a target. It refuses several classes of malicious destination — see below |
| Tested | 134 tests, 95% coverage, run automatically on 3 operating systems |
| API design | Correct HTTP status codes, self-documenting endpoints at /docs |
Deploying it also exposed four bugs that every local test had passed — all documented, because that's the honest argument for deploying things rather than trusting a green test suite.
Hosted on a free instance, which sleeps after fifteen minutes idle: the first request can take about fifty seconds. There's no persistent disk either, so shortened links do not survive a restart. It's a demo, not a service.
$ curl -X POST localhost:8000/api/links \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.com/a/very/long/path"}'
{"code":"Kf7mQx2","short_url":"http://localhost:8000/Kf7mQx2","target":"https://example.com/a/very/long/path","created_at":"2026-08-04T07:52:26.326411+00:00","clicks":0,"last_used_at":null}Needs Python 3.10 or newer.
git clone https://github.com/les-k/snip.git
cd snip
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
python -m snipOpen http://localhost:8000. Interactive API docs are at /docs.
| Variable | Default | |
|---|---|---|
SNIP_DB |
snip.db |
Where the SQLite file lives |
SNIP_HOST |
127.0.0.1 |
Bind address |
SNIP_PORT |
8000 |
Port |
SNIP_BASE_URL |
derived from the request | Set this behind a proxy so short_url is right |
POST |
/api/links |
Create a link. {"url": "...", "code": "optional-alias"} |
GET |
/api/links |
The most recent links, newest first (?limit=) |
GET |
/api/links/{code} |
One link and its click count |
DELETE |
/api/links/{code} |
Remove a link |
GET |
/{code} |
Redirect, and count the visit |
GET |
/api/health |
Liveness and how many links exist |
Failures come back as the status the situation deserves, with a sentence saying what to do about it:
| Status | When |
|---|---|
400 |
The URL or the alias was refused — the message says which and why |
404 |
No link with that code |
409 |
That alias is already taken |
422 |
The request body wasn't the right shape |
A shortener is a small idea, so the substance is in the parts that aren't obvious. Three decisions carry the design.
Base62 of a row id gives shorter codes and never collides. It also makes every
link on the service enumerable: given /3 you can walk to /4 and read
everyone else's. Random codes trade one collision check for the property that
knowing one code tells you nothing about any other. create_unique retries on
collision, so the caller never has to think about it.
The generated alphabet drops 0, O, 1, l and I — codes get read aloud
and retyped from screenshots, and those pairs are where that goes wrong.
Custom aliases are held to a looser standard on purpose. That rule exists to
protect someone transcribing a code they didn't choose; a person typing
my-project-ideas picked every character and would only be annoyed to learn the
letter l was unavailable. Aliases take the full alphanumeric set plus hyphens
and underscores.
Anyone can point this at anything, so urls.py refuses
three classes of destination:
- Non-HTTP schemes. A stored
javascript:URL is a cross-site scripting vector the moment something renders it as a link;file:reaches into the client's disk. - Embedded credentials.
https://evil.com:x@bank.exampledisplays in some clients as though the part before the@were the destination — a ready-made phishing primitive. - Private and loopback addresses. Without this, the service becomes a probe
for whatever is reachable from wherever it's deployed.
169.254.169.254is the address that matters: on most cloud providers it serves instance credentials to anything that asks.
The host is checked as a literal and after DNS resolution, so
internal.example.com pointing at 10.0.0.5 is caught too.
Known gap: resolution happens at creation time, so this doesn't stop DNS rebinding — a domain that resolves publicly when the link is made and privately when it's followed. Closing that means resolving at redirect time and pinning the address, which is the next thing to do here.
A permanent redirect is exactly what a shortener looks like it wants, and it's
the wrong answer: browsers cache 301s, so the second visit never reaches the
server and the click counter silently stops counting. 307 keeps every visit
observable. If you'd rather have the caching than the analytics, that's a
one-line change — but it should be a decision, not an accident.
| File | |
|---|---|
codes.py |
Generating codes, and validating the ones people choose |
urls.py |
Deciding which destinations are acceptable |
store.py |
SQLite persistence and the click counter |
app.py |
Routes, error mapping, static mounting |
static/ |
The landing page — one HTML file, one stylesheet, one script |
The store keeps a single connection behind a lock rather than a pool. SQLite serialises writes anyway, and one connection makes the click increment correct without a transaction dance. A pool is the change to make when reads become the bottleneck, not before.
The landing page stores your history in localStorage and reads it back on
load, so it survives a refresh without the server ever knowing what you shortened.
Both the light and dark themes come from prefers-color-scheme.
pytest134 tests, 95% coverage. CI runs them on Python 3.10–3.13 on Linux plus Windows
and macOS, checks formatting and lint with ruff, and then starts a real server
to shorten a link, follow the redirect, assert the click was counted, and
confirm a request for 169.254.169.254 is refused.
URL validation is tested against an injected resolver, so the private-address checks are exercised without the suite ever touching the network.
docker build -t snip .
docker run -p 8000:8000 -v snip-data:/data snipThe image runs as a non-root user, keeps the database on a volume at /data,
and honours $PORT so it drops into most hosting platforms unchanged.
render.yaml is a blueprint — point Render at this repository and
it configures the service itself. Free tier, no card required.
Known limit: the free tier has no persistent disk, so the SQLite file resets whenever the instance restarts, including after about fifteen minutes idle. Shortened links do not survive that. It's a demo, not a service. Attaching a disk or moving to Postgres is the fix, and neither is free.
| Variable | Why |
|---|---|
SNIP_BASE_URL |
The public origin. Without it, short_url echoes whatever hostname the proxy passed through. A bare hostname is accepted — https:// is added if missing. |
SNIP_DB |
Put this on a persistent volume, or links vanish on restart. |
SNIP_STATIC |
Pins the landing page directory. The container sets it; without it a pip-installed package looks in the wrong place and the page 404s while the API keeps working. |
That last one is worth calling out: resolving the static directory by walking up
from __file__ works from the repository and silently fails once installed into
site-packages. Every test passed locally while the deployed page would have
been broken. tests/test_deployment.py covers that
class of bug specifically — the ones that only appear in a container.
Two things are missing: rate limiting on POST /api/links, and
authentication — anyone who can reach the API can create and delete links.
MIT — see LICENSE.