<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daniel</title>
    <description>The latest articles on DEV Community by Daniel (@daniil_k_af34a83d4d888e4d).</description>
    <link>https://dev.to/daniil_k_af34a83d4d888e4d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4110783%2F74eb116d-5b04-45d6-a739-2e119ba38fd8.png</url>
      <title>DEV Community: Daniel</title>
      <link>https://dev.to/daniil_k_af34a83d4d888e4d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daniil_k_af34a83d4d888e4d"/>
    <language>en</language>
    <item>
      <title>I built a link shortener with FastAPI and htmx (no JS framework) — the parts that were actually hard</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Sat, 05 Sep 2026 12:36:24 +0000</pubDate>
      <link>https://dev.to/daniil_k_af34a83d4d888e4d/i-built-a-link-shortener-with-fastapi-and-htmx-no-js-framework-the-parts-that-were-actually-hard-4o40</link>
      <guid>https://dev.to/daniil_k_af34a83d4d888e4d/i-built-a-link-shortener-with-fastapi-and-htmx-no-js-framework-the-parts-that-were-actually-hard-4o40</guid>
      <description>&lt;p&gt;"A URL shortener" sounds like a weekend project. Slug in, long URL out, &lt;code&gt;302&lt;/code&gt;,&lt;br&gt;
done. That's what I thought too. Then real usage showed up: links opened inside&lt;br&gt;
Instagram's in-app browser and didn't convert, bot traffic wrecked the&lt;br&gt;
analytics, and one link needed to send a US visitor somewhere different from an&lt;br&gt;
EU visitor. Suddenly the "trivial" part was 5% of the work.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;Disclosure: I build &lt;a href="https://tapurl.io" rel="noopener noreferrer"&gt;tapurl.io&lt;/a&gt;, a link shortener for&lt;br&gt;
marketers. This is a write-up of the engineering behind it, not a pitch —&lt;br&gt;
everything below is patterns you can apply to any shortener.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The redirect is a hot path, so treat it like one
&lt;/h2&gt;

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

&lt;p&gt;The naive version hits your database for every redirect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/{slug}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_link&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# DB round-trip on every click
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Link&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;link:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_link&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;link:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth saying out loud:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache the lookup, not the decision.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics must not block the redirect.&lt;/strong&gt; Recording the click (country,
device, referrer) happens &lt;em&gt;after&lt;/em&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer 302 over 301.&lt;/strong&gt; 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 &lt;em&gt;need&lt;/em&gt; a page in between
(more on that in the in-app-browser section): then you serve a lightweight
interstitial instead of redirecting straight away.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  htmx for the dashboard, and I don't miss React
&lt;/h2&gt;

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

&lt;p&gt;The pitch for htmx is that you get partial updates without shipping a SPA. A&lt;br&gt;
button that adds a routing rule just asks the server for the new row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;hx-post=&lt;/span&gt;&lt;span class="s"&gt;"/links/42/rules"&lt;/span&gt;
        &lt;span class="na"&gt;hx-target=&lt;/span&gt;&lt;span class="s"&gt;"#rules"&lt;/span&gt;
        &lt;span class="na"&gt;hx-swap=&lt;/span&gt;&lt;span class="s"&gt;"beforeend"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Add rule
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"rules"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="c"&gt;&amp;lt;!-- server returns one &amp;lt;tr&amp;gt; per rule --&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server returns HTML, not JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/links/{link_id}/rules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RuleForm&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;templates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemplateResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_rule_row.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this fit a solo project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No build step.&lt;/strong&gt; No bundler, no &lt;code&gt;node_modules&lt;/code&gt;, no separate frontend deploy.
The thing that renders the page is the thing that has the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One mental model.&lt;/strong&gt; State lives on the server. I'm not reconciling a client
store with a database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiny payloads.&lt;/strong&gt; Pages ship almost no JS, which — for a product whose whole
value is fast redirects — is on-brand.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  Geo and device routing: one link, many destinations
&lt;/h2&gt;

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

&lt;p&gt;The pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Country from IP.&lt;/strong&gt; 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.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device from the User-Agent.&lt;/strong&gt; Coarse is fine — mobile / desktop / tablet,
plus OS when you need iOS vs Android.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules with priority + a fallback.&lt;/strong&gt; Rules are ordered; the first match
wins; if nothing matches, you &lt;em&gt;must&lt;/em&gt; have a fallback destination. A routing
feature without a fallback is a 404 generator.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pick_destination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="c1"&gt;# already sorted by priority
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fallback_url&lt;/span&gt;           &lt;span class="c1"&gt;# never optional
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  The genuinely hard one: escaping in-app browsers
&lt;/h2&gt;

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

&lt;p&gt;The fix is &lt;strong&gt;deep-linking&lt;/strong&gt;: bounce the user out of the webview into the native&lt;br&gt;
app. On the web platform this leans on &lt;strong&gt;Universal Links (iOS)&lt;/strong&gt; and &lt;strong&gt;App Links&lt;br&gt;
(Android)&lt;/strong&gt;. And here's the honest part that took me longest to accept:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android is reliable.&lt;/strong&gt; App Links resolve cleanly; you can get people into the
native app most of the time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS has a hard ceiling.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

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

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

&lt;h2&gt;
  
  
  Don't let bots into your analytics
&lt;/h2&gt;

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

&lt;p&gt;So bot detection runs &lt;em&gt;before&lt;/em&gt; anything is counted or fired. Known crawler&lt;br&gt;
UAs, headless signatures, and preview fetchers get the redirect (you don't want&lt;br&gt;
to break link previews) but are excluded from analytics and never trigger&lt;br&gt;
pixels. Real humans get counted. It's not glamorous, but it's the difference&lt;br&gt;
between analytics you trust and a dashboard full of lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the loop: conversion postbacks
&lt;/h2&gt;

&lt;p&gt;The last piece, for the affiliate use case, is server-to-server postbacks. You&lt;br&gt;
attach a &lt;code&gt;{click_id}&lt;/code&gt; macro to the outgoing URL; the destination's system returns&lt;br&gt;
it later on a conversion (&lt;code&gt;payout&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;transaction_id&lt;/code&gt;); you match it&lt;br&gt;
back to the original click.&lt;/p&gt;

&lt;p&gt;The one thing that will bite you: &lt;strong&gt;dedup&lt;/strong&gt;. Networks retry and re-fire&lt;br&gt;
postbacks, and a conversion can move &lt;code&gt;pending → approved&lt;/code&gt;. Deduplicate on&lt;br&gt;
&lt;code&gt;(click_id, transaction_id)&lt;/code&gt; and model status as a transition — a conversion&lt;br&gt;
comes in &lt;code&gt;pending&lt;/code&gt; and later flips to &lt;code&gt;approved&lt;/code&gt; — rather than counting every&lt;br&gt;
postback as a new event, or you'll double-count revenue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I do it this way again?
&lt;/h2&gt;

&lt;p&gt;Yes, with one asterisk. FastAPI + Redis + MySQL + htmx let one person ship a&lt;br&gt;
product that does real routing, deep-linking and attribution without a frontend&lt;br&gt;
team or a build pipeline. The redirect stays fast because the hot path is a&lt;br&gt;
Redis read, and the dashboard stays maintainable because there's one source of&lt;br&gt;
truth.&lt;/p&gt;

&lt;p&gt;The asterisk: htmx is a genuine sweet spot for forms-and-lists, but know where&lt;br&gt;
its edge is. The moment you need rich, continuously-updating client state,&lt;br&gt;
you're writing JavaScript again — and that's fine, just don't fight the tool&lt;br&gt;
past its range.&lt;/p&gt;

&lt;p&gt;If you want to see the finished product, it's &lt;a href="https://tapurl.io" rel="noopener noreferrer"&gt;tapurl.io&lt;/a&gt;.&lt;br&gt;
And if you've solved the iOS in-app-browser escape more completely than "attempt&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;visible fallback," I'd genuinely love to hear how — that one still feels
unfinished.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>fastapi</category>
      <category>htmx</category>
    </item>
  </channel>
</rss>
