“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.
The redirect is a hot path, so treat it like one
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.
The naive version hits your database for every redirect:
@app.get("/{slug}")
async def redirect(slug: str):
link = await db.fetch_link(slug) # DB round-trip on every click
if not link:
raise HTTPException(404)
return RedirectResponse(link.destination, status_code=302)
Enter fullscreen mode Exit fullscreen mode
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:
async def resolve(slug: str) -> Link | None:
cached = await redis.get(f"link:{slug}")
if cached:
return Link.parse_raw(cached)
link = await db.fetch_link(slug)
if link:
await redis.set(f"link:{slug}", link.json(), ex=3600)
return link
Enter fullscreen mode Exit fullscreen mode
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.
htmx for the dashboard, and I don’t miss React
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:
<button hx-post="/links/42/rules"
hx-target="#rules"
hx-swap="beforeend">
Add rule
</button>
<div id="rules"><!-- server returns one <tr> per rule --></div>
Enter fullscreen mode Exit fullscreen mode
The server returns HTML, not JSON:
@app.post("/links/{link_id}/rules")
async def add_rule(link_id: int, rule: RuleForm):
saved = await db.create_rule(link_id, rule)
return templates.TemplateResponse("_rule_row.html", {"rule": saved})
Enter fullscreen mode Exit fullscreen mode
Why this fit a solo project:
-
No build step. No bundler, no
node_modules, 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.
Geo and device routing: one link, many destinations
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.
def pick_destination(link, country, device) -> str:
for rule in link.rules: # already sorted by priority
if rule.matches(country, device):
return rule.destination
return link.fallback_url # never optional
Enter fullscreen mode Exit fullscreen mode
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.
The genuinely hard one: escaping in-app browsers
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.
Don’t let bots into your analytics
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.
So bot detection runs before anything is counted or fired. Known crawler
UAs, headless signatures, and preview fetchers get the redirect (you don’t want
to break link previews) but are excluded from analytics and never trigger
pixels. Real humans get counted. It’s not glamorous, but it’s the difference
between analytics you trust and a dashboard full of lies.
Closing the loop: conversion postbacks
The last piece, for the affiliate use case, is server-to-server postbacks. You
attach a {click_id} macro to the outgoing URL; the destination’s system returns
it later on a conversion (payout, status, transaction_id); you match it
back to the original click.
The one thing that will bite you: dedup. Networks retry and re-fire
postbacks, and a conversion can move pending → approved. Deduplicate on
(click_id, transaction_id) and model status as a transition — a conversion
comes in pending and later flips to approved — rather than counting every
postback as a new event, or you’ll double-count revenue.
Would I do it this way again?
Yes, with one asterisk. FastAPI + Redis + MySQL + htmx let one person ship a
product that does real routing, deep-linking and attribution without a frontend
team or a build pipeline. The redirect stays fast because the hot path is a
Redis read, and the dashboard stays maintainable because there’s one source of
truth.
The asterisk: htmx is a genuine sweet spot for forms-and-lists, but know where
its edge is. The moment you need rich, continuously-updating client state,
you’re writing JavaScript again — and that’s fine, just don’t fight the tool
past its range.
If you want to see the finished product, it’s tapurl.io.
And if you’ve solved the iOS in-app-browser escape more completely than “attempt
- visible fallback,” I’d genuinely love to hear how — that one still feels unfinished.