A small, self-hostable URL shortener: a .NET 10 Native-AOT backend that also serves a React admin dashboard — one binary, login, create links, watch their analytics.
- .NET 10, ASP.NET Core minimal API
- SQLite + Dapper.AOT
- Native AOT (single self-contained binary, no JIT)
- Per-click analytics with an in-memory batched write pipeline
- React + TypeScript + Vite dashboard (Recharts), built into the API's
wwwroot - Single-admin auth: password login → opaque session token in an httpOnly cookie (no JWT)
Set an admin password once (stored hashed in a gitignored file, loaded by just run):
just set-password 'your-password'
just run # API + dashboard on http://localhost:5038Open http://localhost:5038 and log in. (The dashboard is served from wwwroot, which is
populated by just web-build or just publish.)
For frontend work, run the Vite dev server alongside the API — two terminals:
just run # terminal 1: backend on :5038
just web-dev # terminal 2: Vite on :5173, proxies /api -> :5038Edit files under web/ and the browser updates instantly. Vite proxies API calls to the
backend, so it behaves like one origin (no CORS).
Common tasks are wrapped in a justfile (requires just,
curl, jq, and — for the dashboard — Node 18+):
just # list recipes
just run # run the API (+ serves the built dashboard)
just set-password <pw> # set the local admin password
just test # run the tests
just web-install # install dashboard deps (web/)
just web-dev # Vite dev server with hot reload
just web-build # build the dashboard into wwwroot
just shorten https://... # create a short link, print the code (run `just login <pw>` first)
just stats <code> # pretty-print a link's analytics
just bench <code> [N] [C] # benchmark the redirect (N requests, C concurrent)The base URL is read from appsettings.json (BaseUrl) — one source of truth, no extra
config files. Override a single call with just base=http://host:port <recipe>, or check
the resolved value with just show-base.
Public:
GET /{code} # 302 to the original URL (404 if unknown); records a clickAuth: POST /api/login with { "password": "..." } sets an httpOnly brevis_session
cookie (204), or 401 on a bad password. POST /api/logout revokes it. All /api/* routes
below require that cookie (the browser sends it automatically); without it they return 401.
Admin — link management (these are what the dashboard calls):
POST /api/links { "longUrl": "https://..." } -> 201 { code, shortUrl }
GET /api/links -> 200 [{ code, longUrl, shortUrl, createdAt, clicks }] (newest first)
GET /api/links/{code} -> 200 { code, longUrl, shortUrl, createdAt, stats } (404 if unknown)
DELETE /api/links/{code} -> 204 (404 if unknown)The stats object on the detail endpoint:
{
"code": "aB3kF9x",
"totalClicks": 1234,
"clicksByDay": [{ "date": "2026-06-01", "count": 90 }],
"topReferrers": [{ "referrer": "news.ycombinator.com", "count": 40 }],
"byCountry": [{ "country": "GB", "count": 600 }],
"byBrowser": [{ "browser": "Chrome", "count": 700 }]
}Every redirect records a click event (timestamp, referrer, user-agent, IP, and geo country/city). The challenge is doing this without slowing the redirect, which is the hot path. So recording is decoupled from persistence:
- The redirect captures the raw click and drops it into a bounded in-memory queue
(
System.Threading.Channels), then returns the302immediately. It never blocks on the database, and never does the geo lookup inline. If the queue is full (extreme load), the event is dropped rather than adding latency. - A background
BackgroundService(ClickWriter) drains the queue, enriches each event with geo data, and bulk-inserts in batches — flushing when the batch reachesBatchSizeorFlushIntervalMselapses, whichever comes first. One transaction per batch means one fsync instead of one per click, and a single writer means zero write-lock contention. On graceful shutdown the queue is drained so a clean stop loses nothing (a hard crash drops in-flight events — an accepted trade for approximate analytics).
Measured effect on the redirect path (3,000 requests, 24 concurrent, Native AOT, SQLite):
| metric | synchronous insert | batched pipeline |
|---|---|---|
| mean | 624 ms | 7.6 ms |
| p50 | 20.7 ms | 0.24 ms |
| p99 | 7,827 ms | 152 ms |
| max | 16,686 ms | 301 ms |
Reproduce with just bench <code> (see Dev tasks).
Geo enrichment uses a MaxMind GeoLite2 City
database, which requires a free MaxMind account + license key to download. Point
Geo:DatabasePath at the .mmdb file. The app runs fine without it — geo lookups simply
no-op and country/city come back as unknown.
Note: MaxMind.Db needs
<TrimmerRootAssembly Include="MaxMind.Db" />(already set in the csproj) to survive Native AOT trimming — without it, opening a database throws at runtime even though the publish reports no warnings.
src/Brevis.API/appsettings.json:
ConnectionStrings:Sqlite— SQLite connection stringBaseUrl— base URL prepended to generated short linksAdmin:PasswordHash— PBKDF2 hash of the admin password (empty = login disabled). Generate withjust hash-password <pw>, or set it for local dev withjust set-password <pw>. In production pass it as theAdmin__PasswordHashenv var rather than committing it.Geo:DatabasePath— path to a GeoLite2.mmdbfile, resolved relative to the app's content root (sogeodata/GeoLite2-City.mmdbworks in bothdotnet runand the published app). Empty = geo disabled.Analytics:BatchSize— flush once this many events are buffered (default 500)Analytics:FlushIntervalMs— max time before a partial batch is flushed (default 1000)Analytics:QueueCapacity— bounded queue size; events are dropped when full (default 10000)
dotnet publish src/Brevis.API -c Release -r linux-x64Use just publish, which builds the dashboard into wwwroot first (needs Node 18+), then
runs the AOT publish. Output (native binary + wwwroot) lands in
bin/Release/net10.0/linux-x64/publish/. The Dockerfile does the same sequence inside the
build container.
Brevis ships as a single self-contained binary serving both the API and the dashboard. Put a
TLS-terminating reverse proxy in front of it — the session cookie is Secure over HTTPS.
docker build -t brevis .
docker run -d --name brevis -p 8080:8080 \
-e BaseUrl='https://brev.example.com/' \
-e Admin__PasswordHash="$(just hash-password 'your-strong-password')" \
-v brevis-data:/app \
brevisBaseUrl— public origin used to build short URLs (include the trailing slash).Admin__PasswordHash— the password hash (__maps to theAdmin:PasswordHashconfig key).-v brevis-data:/app— persists the SQLite database across restarts.
Mount a GeoLite2 database to enable country analytics (see Geo lookups):
-v /path/to/GeoLite2-City.mmdb:/app/geodata/GeoLite2-City.mmdb:roFront it with a reverse proxy that terminates TLS, e.g. Caddy:
brev.example.com {
reverse_proxy localhost:8080
}The app reads X-Forwarded-For for the client IP, so geo/analytics see the real address.
Without Docker, just publish produces the same artifact (binary + wwwroot) under
src/Brevis.API/bin/Release/net10.0/linux-x64/publish/ — copy it to your host, set BaseUrl
and Admin__PasswordHash, and run ./Brevis.API.
dotnet test