shortlink

A self-hosted URL shortener in one binary: SQLite storage, custom slugs,
expiry, QR codes, click analytics (per day, referrer, browser, device) and an
embedded admin UI. No JavaScript framework, no external services, no cgo.
Who it's for
Clubs, small businesses, conference organisers and developers who want short
links on their own domain, for posters (with a QR code), campaigns or
docs, and who'd rather not hand click data to a third party. IP addresses
are never stored.
Install
Prebuilt binaries for Linux, macOS and Windows (amd64 and arm64) are on the
releases page.
Each archive contains the shortlink binary (admin UI embedded), the README and the license; checksums.txt
has the SHA-256 sums. For example, on Linux amd64:
curl -LO https://github.com/AbdeldjalilChougui/shortlink/releases/download/v0.1.0/shortlink_0.1.0_linux_amd64.tar.gz
tar xzf shortlink_0.1.0_linux_amd64.tar.gz
Or build from source with Go:
go install github.com/AbdeldjalilChougui/shortlink/cmd/shortlink@latest
Quick start
go build -o shortlink ./cmd/shortlink
SHORTLINK_TOKEN=change-me ./shortlink -addr :8080 -db shortlink.db -base-url https://s.example.com
Open http://localhost:8080/admin (Basic auth: any user, the token as
password), or use the API:
curl -H "Authorization: Bearer change-me" -d '{"url":"https://example.com/2026/agenda","slug":"agenda","expires":"30d"}' \
http://localhost:8080/api/links
# {"slug":"agenda","short_url":"https://s.example.com/agenda","url":"https://example.com/2026/agenda",...}
curl -I http://localhost:8080/agenda # 302 → destination
curl -H "Authorization: Bearer change-me" 'http://localhost:8080/api/links/agenda?days=30' # stats
| method |
path |
|
| GET |
/{slug} |
302 redirect (404 unknown, 410 expired) |
| POST |
/api/links |
{"url", "slug"?, "expires"?}. expires takes 72h, 30d, 2026-12-31 or RFC 3339 |
| GET |
/api/links |
list |
| GET |
/api/links/{slug}?days=30 |
link + stats |
| DELETE |
/api/links/{slug} |
delete link and its clicks |
| GET |
/admin, /admin/links/{slug} |
UI |
| GET |
/admin/links/{slug}/qr.png?size=256 |
QR code PNG (64–1024 px) |
How it works
- Slugs. Random slugs are 6 characters from an alphabet without
look-alikes (
0/O, 1/l/I), generated with crypto/rand and retried on
collision. Custom slugs are case-insensitive (COLLATE NOCASE) and can't
shadow app routes (admin, api, …).
- Redirects are
302 with Cache-Control: no-store. A 301 would be
cached by browsers forever, so later clicks would go uncounted and expiry
or deletion would be ignored.
- Analytics. Each click stores the time, the UTC day, the referrer
host and a coarse browser and device classification of the User-Agent
(bots such as link-preview fetchers are labelled
bot). The insert and
the counter update share one transaction. Stats fill in empty days, so
the per-day chart has no gaps.
- Admin UI. Server-rendered templates embedded in the binary, with a
CSS bar chart and the QR code inline. Because Basic-auth credentials are
sent on cross-site requests too, state-changing admin requests go through
Go's
http.CrossOriginProtection (Fetch metadata and Origin checks)
against CSRF.
- Storage.
modernc.org/sqlite (pure Go), WAL mode, foreign keys with
cascade delete. QR codes come from github.com/skip2/go-qrcode.
Tests
go test -race ./...
They cover random and custom slugs (collisions, case-insensitive
duplicates, reserved names), expiry (410), the redirect headers, click
recording with referrer and UA, per-day stats with zero-filled days and a
7-day window, cascade delete, auth for UI and API, CSRF rejection of a
cross-site form post, form validation errors, the QR PNG size, expiry
parsing, and a User-Agent table.
Status and limitations
- A single admin token. There are no user accounts or per-user link lists.
- Links can't be edited after creation. Delete and recreate instead.
- Days are bucketed in UTC.
- The User-Agent classification is coarse. Bots that pretend to be browsers
are counted as browsers.
- There's no rate limiting on redirects or the API. Put it behind a proxy
if it's exposed to abuse.
License
MIT. See LICENSE.