Back to News & Insights
Web Development September 5, 2026 · 6 min read

I built a link shortener with FastAPI and htmx (no JS framework) — the parts that were actually hard

"A URL shortener" sounds like a weekend project. Slug in, long URL out, 302, done. That's what I...

I built a link shortener with FastAPI and htmx (no JS framework) — the parts that were actually hard

"A URL shortener" sounds like a weekend project. Slug in, long URL out, 302, done. That's what I thought too. Then real usage showed up: links opened inside Instagram's in-app browser and didn't convert, bot traffic wrecked the analytics, and one link needed to send a US visitor somewhere different from an EU visitor. Suddenly the "trivial" part was 5% of the work.

I built the whole thing on FastAPI + Redis + MySQL + htmx, deliberately with no frontend framework. This post is about the parts that turned out to be interesting — the redirect hot path, geo/device routing, and escaping in-app browsers — and why htmx was the right call for a one-person team.

Disclosure: I build tapurl.io, a link shortener for marketers. This is a write-up of the engineering behind it, not a pitch — everything below is patterns you can apply to any shortener.

Every other page in the app can be a bit slow. The redirect cannot. It sits in front of someone's click, and it runs on every click, so it has to be a tight, predictable read.

That's fine until you have traffic. The slug-to-link lookup is a near-perfect cache candidate — a slug maps to the same link record every time. So the real path reads from Redis first and only falls back to MySQL on a miss:

Two things worth saying out loud: Cache the lookup, not the decision. You cache the link record, but the actual destination is still computed per click from the routing rules (below). And when someone edits a link's rules, invalidate its cache key — otherwise you'll happily serve stale destinations from Redis. Analytics must not block the redirect. Recording the click (country, device, referrer) happens after you've already decided where to send the user — push it to a background task or a queue, never make the visitor wait on a write. Prefer 302 over 301. A permanent redirect gets cached by browsers and you stop seeing clicks. For anything you want to measure — or ever re-point — you want a temporary redirect. The exception is when you need a page in between (more on that in the in-app-browser section): then you serve a lightweight interstitial instead of redirecting straight away.

The dashboard is a normal CRUD app: lists of links, click charts, forms for routing rules. The default 2026 instinct is React + an API. I went the other way: server-rendered Jinja2 templates with htmx for the interactive bits.

The pitch for htmx is that you get partial updates without shipping a SPA. A button that adds a routing rule just asks the server for the new row:

Why this fit a solo project: No build step. No bundler, no nodemodules, no separate frontend deploy. The thing that renders the page is the thing that has the data. One mental model. State lives on the server. I'm not reconciling a client store with a database. Tiny payloads. Pages ship almost no JS, which — for a product whose whole value is fast redirects — is on-brand.

It's not free. Anything genuinely stateful and client-heavy (a live-updating chart) still needs real JavaScript, and htmx doesn't change that. But for a forms-and-lists dashboard, it removed an entire category of work.

This is the first feature that made it not a shortener. The requirement: one short link where a US visitor goes to amazon.com with a US tag and an EU visitor goes to amazon.de with an EU tag — decided at click time.

The pieces: Country from IP. A local MaxMind GeoLite2 database keeps this fast and avoids a network call on the hot path. (Local lookup ≈ microseconds; an external geo-API call would blow your redirect latency budget.) Device from the User-Agent. Coarse is fine — mobile / desktop / tablet, plus OS when you need iOS vs Android. Rules with priority + a fallback. Rules are ordered; the first match wins; if nothing matches, you must have a fallback destination. A routing feature without a fallback is a 404 generator.

The subtle bug I hit: rule order is the whole product. "US → A, everything else → B" and "everything else → B, US → A" are different links, and if your UI lets people reorder rules but your resolver reads them in insert order, you'll ship confident, wrong redirects. Make priority explicit and test it.

Here's the problem almost nobody documents. Someone taps your link inside Instagram or TikTok. It opens in that app's in-app webview — a stripped browser where the user isn't logged into anything. If your link points at a destination that has a native app (YouTube, Spotify, Amazon), the conversion falls off a cliff, because the visitor would have to log in by hand instead of landing in an app where they're already signed in.

The fix is deep-linking: bounce the user out of the webview into the native app. On the web platform this leans on Universal Links (iOS) and App Links (Android). And here's the honest part that took me longest to accept: Android is reliable. App Links resolve cleanly; you can get people into the native app most of the time. iOS has a hard ceiling. You cannot programmatically force an escape from every in-app browser 100% of the time — the platform doesn't allow it. Anyone claiming a silver bullet here is overselling.

So the correct design isn't "guarantee the escape." It's: attempt the escape, and always render a visible fallback button ("Open in app") for the cases the platform won't let you handle silently. Then measure it — track escape attempts vs. successes, split by OS, because Android and iOS numbers are so different that a blended number is meaningless.

The lesson generalizes: when a platform gives you a ceiling, don't hide it behind a claim you can't keep. Design for the ceiling and make the fallback good.

If you fire tracking and count clicks on every request, bots and link-preview crawlers (every time a link is pasted into a chat app, something fetches it) quietly poison your data — and if you fire retargeting pixels, they poison your ad audiences too.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation