PageSourceSearch

https://matthuggins.com/blog/posts/qr-codes-that-route-to-the-appropriate-app-store

html matthuggins.com collected 2026-09-25 15:15:35 UTC 61,248 bytes, 159 lines download raw bytes

1<!DOCTYPE html><html lang="en" dir="ltr"><head><meta charSet="UTF-8"/><meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/><link rel="preload" as="image" href="/blog/qr-codes-that-route-to-the-appropriate-app-store.jpg"/><link rel="preload" as="image" href="https://github.com/mhuggins.png"/><link rel="preload" as="image" href="/assets/pokernexus-BD6Qx7QK.png"/><link rel="preload" as="image" href="/assets/codebound-DrPfa53P.png"/><link rel="preload" as="image" href="/blog/qr-codes-that-route-to-the-appropriate-app-store.thumb.jpg"/><link rel="preload" as="image" href="/blog/announcing-pokernexus.thumb.jpg"/><link rel="preload" as="image" href="/blog/cooking-with-measurable.thumb.jpg"/>
1<script async="" src="https://scripts.simpleanalyticscdn.com/latest.js"></script>
1<meta name="format-detection" content="telephone=no"/><meta name="msapplication-tap-highlight" content="no"/><link rel="icon" type="image/x-icon" href="/assets/icon/favicon.ico"/><title>QR Codes that Route to the Appropriate App Store - Matt Huggins</title><meta name="description" content="A printed QR code holds exactly one URL, but a phone that scans it should land in the right app store. Here&#x27;s the small redirect service behind the PokerNexus business cards: how it picks the App Store, Google Play, or the website, and the crawlers, iPads, caches, and universal links that shaped it."/><meta name="keywords" content="typescript, node.js, native, user experience"/><link rel="canonical" href="https://matthuggins.com/blog/posts/qr-codes-that-route-to-the-appropriate-app-store"/><meta property="og:title" content="QR Codes that Route to the Appropriate App Store"/><meta property="og:description" content="A printed QR code holds exactly one URL, but a phone that scans it should land in the right app store. Here&#x27;s the small redirect service behind the PokerNexus business cards: how it picks the App Store, Google Play, or the website, and the crawlers, iPads, caches, and universal links that shaped it."/><meta property="og:image" content="https://matthuggins.com/blog/qr-codes-that-route-to-the-appropriate-app-store.jpg"/><meta property="og:url" content="https://matthuggins.com/blog/posts/qr-codes-that-route-to-the-appropriate-app-store"/><meta property="og:type" content="article"/><meta property="og:site_name" content="Matt Huggins"/><meta property="article:published_time" content="2026-09-24"/><meta property="article:tag" content="typescript"/><meta property="article:tag" content="node.js"/><meta property="article:tag" content="native"/><meta property="article:tag" content="user experience"/><meta name="twitter:card" content="summary_large_image"/><meta name="twitter:title" content="QR Codes that Route to the Appropriate App Store"/><meta name="twitter:description" content="A printed QR code holds exactly one URL, but a phone that scans it should land in the right app store. Here&#x27;s the small redirect service behind the PokerNexus business cards: how it picks the App Store, Google Play, or the website, and the crawlers, iPads, caches, and universal links that shaped it."/><link rel="stylesheet" href="/assets/main-BS7MJ0qe.css"/></head><body><div id="root"><div class="min-h-dvh bg-gray-100 font-sans text-gray-900 dark:bg-gray-950 dark:text-gray-100"><div class="mx-auto max-w-6xl px-4 py-8 lg:px-4 lg:py-8 print:p-0"><div class="min-h-screen overflow-hidden bg-white not-print:shadow-lg lg:flex dar
1k:bg-gray-900"><main class="overflow-hidden lg:flex-1"><nav class="bg-primary-surface px-4 text-white lg:px-12 print:hidden"><div class="flex items-center justify-between text-sm"><div class="flex text-sm"><a href="/" class="px-4 py-4 font-medium hover:underline">Resume</a><a href="/lab" class="px-4 py-4 font-medium hover:underline">Lab</a><a class="px-4 py-4 font-medium hover:underline bg-primary-light active" href="/blog" data-status="active" aria-current="page">Blog</a></div><div class="flex items-center gap-3"><button type="button" role="switch" aria-checked="false" aria-label="Switch to dark theme" class="relative block h-6 w-12 cursor-pointer rounded-full border-0 p-0 transition duration-300 ease-in-out focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 bg-ink"><span class="pointer-events-none absolute top-0.5 left-0.5 size-5 overflow-hidden rounded-full bg-white transition duration-300 ease-in-out translate-x-0"><span class="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 size-3 rounded-full bg-white"></span><span class="absolute top-[calc(50%_-_7px)] left-[calc(50%_-_1px)] size-2 rounded-full bg-ink transition duration-300 ease-in-out [transform:translate(-3px,3px)_scale(0.2)]"></span><span class="absolute top-0 left-0 size-5 rounded-full transition duration-300 ease-in-out scale-[0.25] [box-shadow:0_0_0_10px_var(--color-ink)_inset]"></span><span style="--angle:0deg" class="absolute top-1/2 left-1/2 h-[3px] w-0.5 origin-top rounded-[1px] bg-ink transition duration-300 ease-in-out [transform:translateX(-50%)_rotate(var(--angle))_translateY(4px)]"></span><span style="--angle:45deg" class="absolute top-1/2 left-1/2 h-[3px] w-0.5 origin-top rounded-[1px] bg-ink transition duration-300 ease-in-out [transform:translateX(-50%)_rotate(var(--angle))_translateY(4px)]"></span><span style="--angle:90deg" class="absolute top-1/2 left-1/2 h-[3px] w-0.5 origin-top rounded-[1px] bg-ink transition duration-300 ease-in-out [transform:translateX(-50%)_rotate(var(--angle))_translateY(4px)]"></span><span style="--angle:135deg" class="absolute top-1/2 left-1/2 h-[3px] w-0.5 origin-top rounded-[1px] bg-ink transition duration-300 ease-in-out [transform:translateX(-50%)_rotate(var(--angle))_translateY(4px)]"></span><span style="--angle:180deg" class="absolute top-1/2 left-1/2 h-[3px] w-0.5 origin-top rounded-[1px] bg-ink transition duration-300 ease-in-out [transform:translateX(-50%)_rotate(var(--angle))_translateY(4px)]"></span><span style="--angle:225deg" class="absolute top-1/2 left-1/2 h-[3px] w-0.5 origin-top rounded-[1px] bg-ink transition duration-300 ease-in-out [transform:translateX(-50%)_rotate(var(--angle))_translateY(4px)]"></span><span style="--angle:270deg" class="absolute top-1/2 left-1/2 h-[3px] w-0.5 origin-top rounded-[1px] bg-ink transition duration-300 ease-in-out [transform:translateX(-50%)_rotate(var(--angle))_translateY(4px)]"></span><span style="--angle:315deg" class="absolute top-1/2 left-1/2 h-[3px] w-0.5 origin-top rounded-[1px] bg-ink transition duration-300 ease-in-out [transform:translateX(-50%)_rotate(var(--angle))_translateY(4px)]"></span></span></button><div class="lg:hidden"><button type="button" class="flex cursor-pointer flex-col gap-1 p-2" aria-label="Toggle menu"><span class="h-0.5 w-6 bg-white transition-all duration-200"></span><span class="h-0.5 w-6 bg-white transition-all duration-200"></span><span class="h-0.5 w-6 bg-white transition-all duration-200"></span></button></div></div></div></nav><div class="p-4 lg:p-16 print:p-0"><!--$?--><template id="B:0"></template><!--/$--></div></main><aside class="hidden shrink-0 bg-primary-light text-white lg:block lg:w-64 print:text-gray-900"><div class="bg-primary-surface p-6 text-center text-white print:py-0"><div class="mx-auto mt-2 mb-4 size-16 overflow-hidden rounded-full border-2 border-gray-100 bg-gray-100 shadow-md md:mt-0 md:size-32 md:border-4"><img src="https://github.com/mhuggins.png" alt="Matt Huggins" class="size-full object-cover"/></div><h1 class="mb-1/2 font-bold text-lg md:mb-1 md:text-3xl">Matt Huggins</h1><p class="font-light text-sm text-white/60 md:text-base print:text-gray-900/60">Web &amp; Mobile Developer</p></div><div class="flex flex-col gap-12 p-6"><div class="flex flex-col items-start gap-4 text-sm"><a href="mailto:[email protected]" target="_blank" rel="noreferrer" title="Email" class="inline-flex items-center gap-2 text-white hover:underline print:text-gray-900/60"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" fill="currentColor" viewBox="0 0 20 20" class="size-5"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path></svg>[email protected]</a><a href="https://www.linkedin.com/in/huggie" target="_blank" rel="noreferrer" title="LinkedIn" class="inline-flex items-center gap-2 text-white hover:underline print:text-gray-900/60"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" fill="currentColor" viewBox="0 0 20 20" class="size-5"><path fill-rule="evenodd" d="M16.338 16.338H13.67V12.16c0-.995-.017-2.277-1.387-2.277-1.39 0-1.601 1.086-1.601 2.207v4.248H8.014v-8.59h2.559v1.174h.037c.356-.675 1.227-1.387 2.526-1.387 2.703 0 3.203 1.778 3.203 4.092v4.711zM5.005 6.575a1.548 1.548 0 11-.003-3.096 1.548 1.548 0 01.003 3.096zm-1.337 9.763H6.34v-8.59H3.667v8.59zM17.668 1H2.328C1.595 1 1 1.581 1 2.298v15.403C1 18.418 1.595 19 2.328 19h15.34c.734 0 1.332
1-.582 1.332-1.299V2.298C19 1.581 18.402 1 17.668 1z" clip-rule="evenodd"></path></svg>huggie</a><a href="https://github.com/mhuggins" target="_blank" rel="noreferrer" title="GitHub" class="inline-flex items-center gap-2 text-white hover:underline print:text-gray-900/60"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" fill="currentColor" viewBox="0 0 20 20" class="size-5"><path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd"></path></svg>mhuggins</a><a href="https://stackoverflow.com/users/107277/matt-huggins" target="_blank" rel="noreferrer" title="StackOverflow" class="inline-flex items-center gap-2 text-white hover:underline print:text-gray-900/60"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" fill="currentColor" viewBox="0 0 24 24" class="size-5"><path d="M18.986 21.865v-6.404h2.134V24H1.844v-8.539h2.13v6.404h15.012zM6.111 19.731H16.85v-2.137H6.111v2.137zm.259-4.852l10.48 2.189.451-2.07-10.478-2.187-.453 2.068zm1.359-5.056l9.705 4.53.903-1.95-9.706-4.53-.902 1.95zm2.715-4.785l8.217 6.855 1.359-1.62-8.216-6.853-1.36 1.618zM15.539 0l-1.7 1.265 6.397 8.604 1.697-1.265L15.539 0zM6.109 17.592H16.85v-2.138H6.109v2.138z"></path></svg>matt-huggins</a></div><div class="flex flex-col gap-3"><div class="font-semibold uppercase">Projects</div><div class="text-sm"><div class="mb-1 font-semibold"><a href="https://pokernexus.com" target="_blank" class="inline-flex items-center gap-2 hover:underline"><img src="/assets/pokernexus-BD6Qx7QK.png" class="size-4 drop-shadow-md"/>PokerNexus</a></div><div class="text-white/60 leading-tight print:text-gray-900/60">Poker social network</div></div><div class="text-sm"><div class="mb-1 font-semibold"><a href="https://codebound.io" target="_blank" class="inline-flex items-center gap-2 hover:underline"><img src="/assets/codebound-DrPfa53P.png" class="size-4 drop-shadow-md"/>Codebound</a></div><div class="text-white/60 leading-tight print:text-gray-900/60">Collectible card game</div></div><div class="text-sm"><div class="mb-1 font-semibold"><a href="https://svgconverter.io" target="_blank" class="inline-flex items-center gap-2 hover:underline"><img src="data:image/svg+xml,%3csvg%20xmlns=&#x27;http://www.w3.org/2000/svg&#x27;%20version=&#x27;1.1&#x27;%20viewBox=&#x27;0%200%2032%2032&#x27;
1%3e%3cdefs%3e%3c!--%20Define%20mask%20for%20center%20hole%20--%3e%3cmask%20id=&#x27;center-hole&#x27;%3e%3crect%20x=&#x27;0&#x27;%20y=&#x27;0&#x27;%20width=&#x27;32&#x27;%20height=&#x27;32&#x27;%20fill=&#x27;white&#x27;%20/%3e%3ccircle%20cx=&#x27;16&#x27;%20cy=&#x27;16&#x27;%20r=&#x27;3&#x27;%20fill=&#x27;black&#x27;%20/%3e%3c/mask%3e%3c!--%20Define%20circle%20masks%20for%20each%20segment%20--%3e%3cmask%20id=&#x27;circle-mask-0&#x27;%3e%3crect%20x=&#x27;0&#x27;%20y=&#x27;0&#x27;%20width=&#x27;32&#x27;%20height=&#x27;32&#x27;%20fill=&#x27;white&#x27;%20/%3e%3ccircle%20cx=&#x27;22.47213595499958&#x27;%20cy=&#x27;11.297717981660215&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;black&#x27;%20/%3e%3c/mask%3e%3cmask%20id=&#x27;circle-mask-1&#x27;%3e%3crect%20x=&#x27;0&#x27;%20y=&#x27;0&#x27;%20width=&#x27;32&#x27;%20height=&#x27;32&#x27;%20fill=&#x27;white&#x27;%20/%3e%3ccircle%20cx=&#x27;22.47213595499958&#x27;%20cy=&#x27;20.702282018339787&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;black&#x27;%20/%3e%3c/mask%3e%3cmask%20id=&#x27;circle-mask-2&#x27;%3e%3crect%20x=&#x27;0&#x27;%20y=&#x27;0&#x27;%20width=&#x27;32&#x27;%20height=&#x27;32&#x27;%20fill=&#x27;white&#x27;%20/%3e%3ccircle%20cx=&#x27;13.52786404500042&#x27;%20cy=&#x27;23.60845213036123&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;black&#x27;%20/%3e%3c/mask%3e%3cmask%20id=&#x27;circle-mask-3&#x27;%3e%3crect%20x=&#x27;0&#x27;%20y=&#x27;0&#x27;%20width=&#x27;32&#x27;%20height=&#x27;32&#x27;%20fill=&#x27;white&#x27;%20/%3e%3ccircle%20cx=&#x27;8&#x27;%20cy=&#x27;16&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;black&#x27;%20/%3e%3c/mask%3e%3cmask%20id=&#x27;circle-mask-4&#x27;%3e%3crect%20x=&#x27;0&#x27;%20y=&#x27;0&#x27;%20width=&#x27;32&#x27;%20height=&#x27;32&#x27;%20fill=&#x27;white&#x27;%20/%3e%3ccircle%20cx=&#x27;13.52786404500042&#x27;%20cy=&#x27;8.39154786963877&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;black&#x27;%20/%3e%3c/mask%3e%3c/defs%3e%3cg%20mask=&#x27;url(%23center-hole)&#x27;%3e%3c!--%20Red%20--%3e%3cg%20mask=&#x27;url(%23circle-mask-0)&#x27;%3e%3ccircle%20cx=&#x27;22.47213595499958&#x27;%20cy=&#x27;20.702282018339787&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;%23EF4444&#x27;%20/%3e%3c/g%3e%3c!--%20Orange%20--%3e%3cg%20mask=&#x27;url(%23circle-mask-1)&#x27;%3e%3ccircle%20cx=&#x27;13.52786404500042&#x27;%20cy=&#x27;23.60845213036123&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;%23F59E0B&#x27;%20/%3e%3c/g%3e%3c!--%20Green%20--%3e%3cg%20mask=&#x27;url(%23circle-mask-2)&#x27;%3e%3ccircle%20cx=&#x27;8&#x27;%20cy=&#x27;16&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;%2322C55E&#x27;%20/%3e%3c/g%3e%3c!--%20Cyan%20--%3e%3cg%20mask=&#x27;url(%23circle-mask-3)&#x27;%3e%3ccircle%20cx=&#x27;13.52786404500042&#x27;%20cy=&#x27;8.39154786963877&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;%2306B6D4&#x27;%20/%3e%3c/g%3e%3c!--%20Purple%20--%3e%3cg%20mask=&#x27;url(%23circle-mask-4)&#x27;%3e%3ccircle%20cx=&#x27;22.47213595499958&#x27;%20cy=&#x27;11.297717981660213&#x27;%20r=&#x27;8&#x27;%20fill=&#x27;%238B5CF6&#x27;%20/%3e%3c/g%3e%3c/g%3e%3c/svg%3e" class="size-4 drop-shadow-md"/>SVGConverter.io</a></div><div class="text-white/60 leading-tight print:text-gray-900/60">Vectorize raster images</div></div></div><div class="flex flex-col gap-3"><div class="font-semibold uppercase">Recent Blog Posts</div><div class="text-sm"><div class="mb-1 font-semibold"><a class="group flex flex-col gap-2 hover:underline active" href="/blog/posts/qr-codes-that-route-to-the-appropriate-app-store" data-status="active" aria-current="page"><img src="/blog/qr-codes-that-route-to-the-appropriate-app-store.thumb.jpg" alt="QR Codes that Route to the Appropriate App Store" class="w-full rounded-sm border border-white/20 shadow-md"/>QR Codes that Route to the Appropriate App Store</a></div><div class="text-white/60 leading-tight print:text-gray-900/60">Sep 24, 2026</div></div><div class="text-sm"><div class="mb-1 font-semibold"><a href="/blog/posts/announcing-pokernexus" class="group flex flex-col gap-2 hover:underline"><img src="/blog/announcing-pokernexus.thumb.jpg" alt="Announcing PokerNexus: The Social Network for Poker Players" class="w-full rounded-sm border border-white/20 shadow-md"/>Announcing PokerNexus: The Social Network for Poker Players</a></div><div class="text-white/60 leading-tight print:text-gray-900/60">Sep 21, 2026</div></div><div class="text-sm"><div class="mb-1 font-semibold"><a href="/blog/posts/cooking-with-measurable" class="group flex flex-col gap-2 hover:underline"><img src="/blog/cooking-with-measurable.thumb.jpg" alt="Cooking with measurable" class="w-full rounded-sm border border-white/20 shadow-md"/>Cooking with measurable</a></div><div class="text-white/60 leading-tight print:text-gray-900/60">Jun 19, 2026</div></div></div><div class="flex flex-col gap-3"><div class="font-semibold uppercase">Blog Topics</div><div><div class="flex flex-wrap gap-2"><a href="/blog/tags/ai" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">ai</a><a href="/blog/tags/data%20fetching" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">data fetching</a><a href="/blog/tags/form%20management" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">form management</a><a href="/blog/tags/graphics" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">graphics</a><a href="/blog/tags/graphql" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">graphql</a><a href="/blog/tags/javascript" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">javascript</a><a href="/blog/tags/native" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">native</a><a href="/blog/tags/node.js" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">node.js</a><a href="/blog/tags/react" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">react</a><a href="/blog/tags/ruby" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">ruby</a><a href="/blog/tags/ruby%20on%20rails" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">ruby on rails</a><a href="/blog/tags/security" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">security</a><a href="/blog/tags/tanstack%20form" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">tanstack form</a><a href="/blog/tags/tanstack%20query" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">tanstack query</a><a href="/blog/tags/typescript" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">typescript</a><a href="/blog/tags/user%20experience" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">user experience</a></div></div></div></div></aside></div></div></div></div>
1<script type="module" src="/assets/main-CQMaxav1.js"></script>
1<script id="_R_">requestAnimationFrame(function(){$RT=performance.now()});</script>
1<div hidden id="S:0">
1<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"QR Codes that Route to the Appropriate App Store","description":"A printed QR code holds exactly one URL, but a phone that scans it should land in the right app store. Here's the small redirect service behind the PokerNexus business cards: how it picks the App Store, Google Play, or the website, and the crawlers, iPads, caches, and universal links that shaped it.","keywords":"typescript, node.js, native, user experience","url":"https://matthuggins.com/blog/posts/qr-codes-that-route-to-the-appropriate-app-store","datePublished":"2026-09-24","author":{"@type":"Person","name":"Matt Huggins","url":"https://matthuggins.com"}}</script>
1<div class="flex flex-col gap-12"><section class="flex flex-col gap-4"><div class="flex flex-col gap-1"><h2 class="flex items-center gap-2 font-normal text-primary-dark text-xl tracking-wide uppercase"><span class="print-exact rounded-full bg-primary-dark p-2 text-white dark:bg-primary"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256"><path d="M232,48H168a32,32,0,0,0-32,32v87.73a8.17,8.17,0,0,1-7.47,8.25,8,8,0,0,1-8.53-8V80A32,32,0,0,0,88,48H24a8,8,0,0,0-8,8V200a8,8,0,0,0,8,8H96a24,24,0,0,1,24,23.94,7.9,7.9,0,0,0,5.12,7.55A8,8,0,0,0,136,232a24,24,0,0,1,24-24h72a8,8,0,0,0,8-8V56A8,8,0,0,0,232,48ZM208,168H168.27a8.17,8.17,0,0,1-8.25-7.47,8,8,0,0,1,8-8.53h39.73a8.17,8.17,0,0,1,8.25,7.47A8,8,0,0,1,208,168Zm0-32H168.27a8.17,8.17,0,0,1-8.25-7.47,8,8,0,0,1,8-8.53h39.73a8.17,8.17,0,0,1,8.25,7.47A8,8,0,0,1,208,136Zm0-32H168.27A8.17,8.17,0,0,1,160,96.53,8,8,0,0,1,168,88h39.73A8.17,8.17,0,0,1,216,95.47,8,8,0,0,1,208,104Z"></path></svg></span>QR Codes that Route to the Appropriate App Store</h2><div class="text-gray-500 text-sm dark:text-gray-400 ml-11"><div class="flex flex-col items-start justify-start gap-2"><div class="flex flex-col gap-2 lg:flex-row lg:items-center"><span class="whitespace-nowrap">Sep 24, 2026</span><div class="flex flex-wrap gap-2"><a href="/blog/tags/typescript" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">typescript</a><a href="/blog/tags/node.js" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">node.js</a><a href="/blog/tags/native" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">native</a><a href="/blog/tags/user%20experience" class="hover:underline print-exact rounded-full bg-gray-100 px-3 py-1 font-medium text-gray-700 text-xs transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100">user experience</a></div></div><a class="inline-flex items-center gap-2 text-primary hover:underline print:hidden active" href="/blog" data-status="active" aria-current="page"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 256 256" class="size-4"><path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"></path></svg>Back to Blog</a></div></div></div><div><img src="/blog/qr-codes-that-route-to-the-appropriate-app-store.jpg" alt="QR Codes that Route to the Appropriate App Store" class="mx-auto mt-4 mb-8 rounded-lg border-2 border-slate-200 shadow-lg dark:border-gray-700"/><div class="prose prose-gray dark:prose-invert max-w-none"><p>When I <a href="/blog/posts/announcing-pokernexus">announced PokerNexus</a> earlier this week, I mentioned in passing that it deploys &quot;a separate shortlink host for printed links.&quot; This post covers what that host does and why it&#x27;s built the way it is.</p>
2<p>The goal was simple. I wanted business cards with a QR code on the back that says &quot;Download.&quot; The trouble is that a QR code encodes exactly one URL, while &quot;download the app&quot; means three different places depending on who scans it:</p>
3<ul>
4<li>An iPhone should land on the <a href="https://apps.apple.com/app/id6800184564">App Store</a> listing.</li>
5<li>An Android phone should land on the <a href="https://play.google.com/store/apps/details?id=com.pokernexus.app">Google Play</a> listing.</li>
6<li>Everything else (a laptop, a crawler, a device I can&#x27;t identify) should land on <a href="https://pokernexus.com">the website</a>.</li>
7</ul>
8<p>Printed cards also can&#x27;t be updated once they&#x27;re in someone&#x27;s wallet, so the URL on them needs to keep working even if the destinations change later. Both problems point to the same answer: print a URL I control, and let the server decide where each request goes. The cards point at <code>https://go.pokernexus.com/app</code>.</p>
9<h2>Designing the code</h2>
10<p>I made the QR code itself with my <a href="/lab/qr-code-generator">QR Code Generator</a>. It runs entirely in the browser and exports a plain SVG or PNG, which is what a print shop wants.</p>
11<p>A couple of details matter more on a business card than on a screen. A short URL produces a less dense code, and a less dense code has larger modules that scan more reliably at business card size. <code>go.pokernexus.com/app</code> is short enough to stay at a low QR version. Putting a logo in the middle covers some of the modules, so the code has to lean on error correction to make up for them. The generator bumps the level to H (roughly 30% recoverable) as soon as a center icon is added, which is what let the PokerNexus club sit in the middle without breaking scans.</p>
12<h2>The redirect service</h2>
13<p>The host is served by a small standalone <a href="https://hono.dev">Hono</a> app. Every place it can send someone is described as a <code>Destination</code>: a URL, plus a flag that says whether the incoming query string should be copied onto it.</p>
14<pre class="shiki shiki-themes github-light github-dark" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0" data-language="ts"><div class="code-block-header"><span class="font-mono text-xs text-gray-600">ts</span><button class="copy-code-btn ml-2" title="Copy code" data-code="interface Destination {
15  readonly url: string;
16  readonly query: &quot;forward&quot; | &quot;drop&quot;;
17}
18
19const WEBSITE: Destination = {
20  url: &quot;https://pokernexus.com&quot;,
21  query: &quot;forward&quot;,
22};
23
24const APP_STORE: Destination = {
25  url: &quot;https://apps.apple.com/app/id6800184564&quot;,
26  query: &quot;drop&quot;,
27};
28
29const PLAY_STORE: Destination = {
30  url: &quot;https://play.google.com/store/apps/details?id=com.pokernexus.app&quot;,
31  query: &quot;drop&quot;,
32};"></button></div><code><span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">interface</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Destination</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
33<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  readonly</span><span style="color:#E36209;--shiki-dark:#FFAB70"> url</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> string</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
34<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  readonly</span><span style="color:#E36209;--shiki-dark:#FFAB70"> query</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#032F62;--shiki-dark:#9ECBFF"> &quot;forward&quot;</span><span style="color:#D73A49;--shiki-dark:#F97583"> |</span><span style="color:#032F62;--shiki-dark:#9ECBFF"> &quot;drop&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
35<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">}</span></span>
36<span class="line"></span>
37<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> WEBSITE</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Destination</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
38<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  url: </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;https://pokernexus.com&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">,</span></span>
39<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  query: </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;forward&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">,</span></span>
40<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">};</span></span>
41<span class="line"></span>
42<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> APP_STORE</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Destination</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
43<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  url: </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;https://apps.apple.com/app/id6800184564&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">,</span></span>
44<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  query: </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;drop&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">,</span></span>
45<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">};</span></span>
46<span class="line"></span>
47<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> PLAY_STORE</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Destination</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
48<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  url: </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;https://play.google.com/store/apps/details?id=com.pokernexus.app&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">,</span></span>
49<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  query: </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;drop&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">,</span></span>
50<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">};
50</span></span></code></pre>
51<p>The website appears twice because the two paths treat query strings differently. I&#x27;ll come back to why in the section on query strings.</p>
52<p>The routing table maps each path to a <code>Target</code>, which is either a fixed <code>Destination</code> or a function that picks one based on the request:</p>
53<pre class="shiki shiki-themes github-light github-dark" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0" data-language="ts"><div class="code-block-header"><span class="font-mono text-xs text-gray-600">ts</span><button class="copy-code-btn ml-2" title="Copy code" data-code="export type Target = Destination | ((request: Request) =&gt; Destination);"></button></div><code><span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">export</span><span style="color:#D73A49;--shiki-dark:#F97583"> type</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Target</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Destination</span><span style="color:#D73A49;--shiki-dark:#F97583"> |</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> ((</span><span style="color:#E36209;--shiki-dark:#FFAB70">request</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Request</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) </span><span style="color:#D73A49;--shiki-dark:#F97583">=&gt;</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Destination</span><span style="color:#24292E;--shiki-dark:#E1E4E8">);</span></span></code></pre>
54<p>It&#x27;s two entries long:</p>
55<pre class="shiki shiki-themes github-light github-dark" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0" data-language="ts"><div class="code-block-header"><span class="font-mono text-xs text-gray-600">ts</span><button class="copy-code-btn ml-2" title="Copy code" data-code="export const links: Readonly&lt;Record&lt;string, Target&gt;&gt; = {
56  &quot;/&quot;: { ...WEBSITE, query: &quot;drop&quot; },
57  &quot;/app&quot;: (request) =&gt; storeFor(request.headers.get(&quot;user-agent&quot;)),
58};"></button></div><code><span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">export</span><span style="color:#D73A49;--shiki-dark:#F97583"> const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> links</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Readonly</span><span style="color:#24292E;--shiki-dark:#E1E4E8">&lt;</span><span style="color:#6F42C1;--shiki-dark:#B392F0">Record</span><span style="color:#24292E;--shiki-dark:#E1E4E8">&lt;</span><span style="color:#005CC5;--shiki-dark:#79B8FF">string</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#6F42C1;--shiki-dark:#B392F0">Target</span><span style="color:#24292E;--shiki-dark:#E1E4E8">&gt;&gt; </span><span style="color:#D73A49;--shiki-dark:#F97583">=</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
59<span class="line"><span style="color:#032F62;--shiki-dark:#9ECBFF">  &quot;/&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">: { </span><span style="color:#D73A49;--shiki-dark:#F97583">...</span><span style="color:#005CC5;--shiki-dark:#79B8FF">WEBSITE</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, query: </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;drop&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> },</span></span>
60<span class="line"><span style="color:#032F62;--shiki-dark:#9ECBFF">  &quot;/app&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">: (</span><span style="color:#E36209;--shiki-dark:#FFAB70">request</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) </span><span style="color:#D73A49;--shiki-dark:#F97583">=&gt;</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> storeFor</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(request.headers.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">
60get</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(</span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;user-agent&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">)),</span></span>
61<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">};</span></span></code></pre>
62<p><code>/</code> sends visitors to the website, and <code>/app</code> asks <code>storeFor</code> which destination fits the device. The decision is made on the <code>User-Agent</code> header alone:</p>
63<pre class="shiki shiki-themes github-light github-dark" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0" data-language="ts"><div class="code-block-header"><span class="font-mono text-xs text-gray-600">ts</span><button class="copy-code-btn ml-2" title="Copy code" data-code="const BOT = /bot|crawl|spider|slurp|preview|facebookexternalhit/i;
64const IOS = /iPhone|iPod|iPad/;
65const ANDROID = /Android/;
66
67export const storeFor = (userAgent: string | null | undefined): Destination =&gt; {
68  if (userAgent === null || userAgent === undefined) {
69    return WEBSITE;
70  }
71  if (BOT.test(userAgent)) {
72    return WEBSITE;
73  }
74  if (IOS.test(userAgent)) {
75    return APP_STORE;
76  }
77  if (ANDROID.test(userAgent)) {
78    return PLAY_STORE;
79  }
80  return WEBSITE;
81};"></button></div><code><span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> BOT</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#032F62;--shiki-dark:#9ECBFF"> /</span><span style="color:#032F62;--shiki-dark:#DBEDFF">bot</span><span style="color:#D73A49;--shiki-dark:#F97583">|</span><span style="color:#032F62;--shiki-dark:#DBEDFF">crawl</span><span style="color:#D73A49;--shiki-dark:#F97583">|</span><span style="color:#032F62;--shiki-dark:#DBEDFF">spider</span><span style="color:#D73A49;--shiki-dark:#F97583">|</span><span style="color:#032F62;--shiki-dark:#DBEDFF">slurp</span><span style="color:#D73A49;--shiki-dark:#F97583">|</span><span style="color:#032F62;--shiki-dark:#DBEDFF">preview</span><span style="color:#D73A49;--shiki-dark:#F97583">|</span><span style="color:#032F62;--shiki-dark:#DBEDFF">facebookexternalhit</span><span style="color:#032F62;--shiki-dark:#9ECBFF">/</span><span style="color:#D73A49;--shiki-dark:#F97583">i</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
82<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> IOS</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#032F62;--shiki-dark:#9ECBFF"> /</span><span style="color:#032F62;--shiki-dark:#DBEDFF">iPhone</span><span style="color:#D73A49;--shiki-dark:#F97583">|</span><span style="color:#032F62;--shiki-dark:#DBEDFF">iPod</span><span style="color:#D73A49;--shiki-dark:#F97583">|</span><span style="color:#032F62;--shiki-dark:#DBEDFF">iPad</span><span style="color:#032F62;--shiki-dark:#9ECBFF">/</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
83<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> ANDROID</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#032F62;--shiki-dark:#9ECBFF"> /</span><span style="color:#032F62;--shiki-dark:#DBEDFF">Android</span><span style="color:#032F62;--shiki-dark:#9ECBFF">/</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
84<span class="line"></span>
85<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">export</span><span style="color:#D73A49;--shiki-dark:#F97583"> const</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> storeFor</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="color:#E36209;--shiki-dark:#FFAB70">userAgent</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> string</span><span style="color:#D73A49;--shiki-dark:#F97583"> |</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> null</span><span style="color:#D73A49;--shiki-dark:#F97583"> |</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> undefined</span><span style="color:#24292E;--shiki-dark:#E1E4E8">)</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Destination</span><span style="color:#D73A49;--shiki-dark:#F97583"> =&gt;</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
86<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (userAgent </span><span style="color:#D73A49;--shiki-dark:#F97583">===</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> null</span><span style="color:#D73A49;--shiki-dark:#F97583"> ||</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> userAgent </span><span style="color:#D73A49;--shiki-dark:#F97583">===</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> undefined</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
87<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    return</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> WEBSITE</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
88<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  }</span></span>
89<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="color:#005CC5;--shiki-dark:#79B8FF">BOT</span><span style="color:#24292E;--shiki-dark:#E1E4E8">.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">test</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(userAgent)) {</span></span>
90<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    return</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> WEBSITE</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
91<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  }</span></span>
92<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="color:#005CC5;--shiki-dark:#79B8FF">IOS</span><span style="color:#24292E;--shiki-dark:#E1E4E8">.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">test</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(userAgent)) {</span></span>
93<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    return</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> APP_STORE</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
94<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  }</span></span>
95<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="color:#005CC5;--shiki-dark:#79B8FF">ANDROID</span><span style="color:#24292E;--shiki-dark:#E1E4E8">.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">test</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(userAgent)) {</span></span>
96<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    return</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> PLAY_STORE</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
97<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  }</span></span>
98<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  return</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> WEBSITE</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
99<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">};
99</span></span></code></pre>
100<p>Every branch that isn&#x27;t a confident match falls through to the website. That asymmetry is intentional. A wrong answer that lands on the website is still a working page, while a wrong answer that lands in a store is an install prompt for a device that can&#x27;t run the app.</p>
101<h3>Bots are checked first</h3>
102<p>The order of those checks is easy to overlook. Google&#x27;s smartphone crawlers identify as phones and append their own name, so the Android crawler&#x27;s user agent contains <code>Android</code> and the iOS one contains <code>iPhone</code>:</p>
103<pre><code>Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) ... (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
104</code></pre>
105<p>If the device checks ran first, Googlebot would be sent to a store listing while a person on a laptop was sent to the website. Serving crawlers a different destination than people is what cloaking looks like, and it isn&#x27;t something I want a search engine to infer. Checking for bots first sends every crawler to the same place a desktop visitor goes.</p>
106<p>The bot pattern is a loose substring match on purpose. A false positive costs one phone the store link and lands it on the website instead. A false negative puts a crawler in a store. Those mistakes aren&#x27;t the same size, so the pattern errs toward the website.</p>
107<h3>The iPad problem</h3>
108<p>Chrome on an iPad names the device in its user agent, so the <code>iPad</code> in the iOS pattern catches it. Safari on an iPad doesn&#x27;t. Since iPadOS 13 it requests the desktop version of sites by default, and the user agent it sends is byte-for-byte what Safari on a Mac sends:</p>
109<pre><code>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Safari/605.1.15
110</code></pre>
111<p>Chromium-based browsers offer a more structured alternative to the user agent string called <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Client_hints">client hints</a>. These are request headers prefixed with <code>Sec-CH-UA-</code>, such as <code>Sec-CH-UA-Platform: &quot;Android&quot;</code> and <code>Sec-CH-UA-Mobile: ?1</code>, that describe the browser, platform, and device type in separate fields rather than one long string. Safari doesn&#x27;t send them, though, so there&#x27;s no header that separates an iPad from a Mac.</p>
112<p>My first attempt at this was a tiny interstitial page for any <code>Macintosh</code> user agent. It checked <code>navigator.maxTouchPoints</code> in JavaScript (iPads report touch points, Macs don&#x27;t) and called <code>location.replace</code> with the right destination. It worked, but I removed it in the same pull request. It cost every Mac visitor an extra round trip and a flash of a blank page. It also replaced a cacheable <code>302</code> with a <code>200</code> that had to be told not to be cached, and it put a script on a host whose whole design is a lookup table and a branch.</p>
113<p>So Safari on an iPad knowingly lands on the website, which is an accepted trade-off. The website has App Store and Google Play badges on its About page, so the fallback isn&#x27;t a dead end for those visitors.</p>
114<h2>A 302, not a 301</h2>
115<p>The catch-all handler resolves the path and redirects:</p>
116<pre class="shiki shiki-themes github-light github-dark" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0" data-language="ts"><div class="code-block-header"><span class="font-mono text-xs text-gray-600">ts</span><button class="copy-code-btn ml-2" title="Copy code" data-code="app.on([&quot;GET&quot;, &quot;HEAD&quot;], &quot;/*&quot;, (c) =&gt; {
117  const destination = resolve(c.req.path, c.req.raw);
118  if (destination === null) {
119    return c.notFound();
120  }
121  c.header(&quot;Vary&quot;, &quot;User-Agent&quot;);
122  return c.redirect(locationFor(destination, c.req.url), 302);
123});"></button></div><code><span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">on</span><span style="color:#24292E;--shiki-dark:#E1E4E8">([</span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;GET&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;HEAD&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;/*&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, (</span><span style="color:#E36209;--shiki-dark:#FFAB70">c</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) </span><span style="color:#D73A49;--shiki-dark:#F97583">=&gt;</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
124<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> destination</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> resolve</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(c.req.path, c.req.raw);</span></span>
125<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (destination </span><span style="color:#D73A49;--shiki-dark:#F97583">===</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> null</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
126<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    return</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> c.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">notFound</span><span style="color:#24292E;--shiki-dark:#E1E4E8">();</span></span>
127<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  }</span></span>
128<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  c.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">header</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(</span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;Vary&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#032F62;--shiki-dark:#9ECBFF">&quot;User-Agent&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">);</span></span>
129<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  return</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> c.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">redirect</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(</span><span style="color:#6F42C1;--shiki-dark:#B392F0">locationFor</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(destination, c.req.url), </span><span style="color:#005CC5;--shiki-dark:#79B8FF">302</span><span style="color:#24292E;--shiki-dark:#E1E4E8">);</span></span>
130<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">});
130</span></span></code></pre>
131<p>It&#x27;s tempting to reach for a <code>301</code> because the printed URL is permanent. But a permanent redirect gets cached by the browser and anything sitting in front of it. A phone that changes hands would keep the previous owner&#x27;s answer, and repointing a printed link later would stop working for anyone who had already scanned it. Repointing a printed link is one of the main reasons to use a redirect host, so the redirect is temporary.</p>
132<p>The <code>Vary</code> header covers the same concern at the cache layer. By default, a cache (the browser&#x27;s own, a CDN, or a proxy on a corporate network) stores a response keyed by its URL alone, and serves that stored response to the next request for the same URL. <code>Vary</code> tells the cache which request headers also affected the response, so it only reuses a stored copy when those headers match too. <code>/app</code> returns three different destinations for the same URL, so without <code>Vary: User-Agent</code>, a shared cache could store the redirect it gave an Android phone and hand that Play Store link to the next iPhone.</p>
133<p><code>HEAD</code> is answered alongside <code>GET</code> because link checkers often use it, and a link checker pointed at a printed address should see the same <code>302</code> a phone does.</p>
134<h2>Query strings</h2>
135<p>Tagging a printed link with a campaign (<code>?utm_source=cards&amp;utm_medium=qr</code>) is useful, so the website fallback forwards the incoming query. The store destinations drop it:</p>
136<pre class="shiki shiki-themes github-light github-dark" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0" data-language="ts"><div class="code-block-header"><span class="font-mono text-xs text-gray-600">ts</span><button class="copy-code-btn ml-2" title="Copy code" data-code="export const locationFor = (destination: Destination, requestUrl: string): string =&gt; {
137  const url = new URL(destination.url);
138  if (destination.query === &quot;forward&quot;) {
139    url.search = new URL(requestUrl).search;
140  }
141  return url.toString();
142};"></button></div><code><span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">export</span><span style="color:#D73A49;--shiki-dark:#F97583"> const</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> locationFor</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="color:#E36209;--shiki-dark:#FFAB70">destination</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Destination</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#E36209;--shiki-dark:#FFAB70">requestUrl</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> string</span><span style="color:#24292E;--shiki-dark:#E1E4E8">)</span><span style="color:#D73A49;--shiki-dark:#F97583">:</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> string</span><span style="color:#D73A49;--shiki-dark:#F97583"> =&gt;</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
143<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> url</span><span style="color:#D73A49;--shiki-dark:#F97583"> =</span><span style="color:#D73A49;--shiki-dark:#F97583"> new</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> URL</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(destination.url);</span></span>
144<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (destination.query </span><span style="color:#D73A49;--shiki-dark:#F97583">===</span><span style="color:#032F62;--shiki-dark:#9ECBFF"> &quot;forward&quot;</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
145<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">    url.search </span><span style="color:#D73A49;--shiki-dark:#F97583">=</span><span style="color:#D73A49;--shiki-dark:#F97583"> new</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> URL</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(requestUrl).search;</span></span>
146<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  }</span></span>
147<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  return</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> url.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">toString</span><span style="color:#24292E;--shiki-dark:#E1E4E8">();</span></span>
148<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">};
148</span></span></code></pre>
149<p>Dropping it for Google Play isn&#x27;t optional. The Play listing&#x27;s package ID lives in the query string (<code>?id=com.pokernexus.app</code>), and <code>locationFor</code> replaces the query rather than merging it. Forwarding a campaign tag there would overwrite the ID and open a listing for nothing. A test pins the Play destination to <code>&quot;drop&quot;</code> so that can&#x27;t change quietly.</p>
150<h2>Why not <code>pokernexus.com/app</code>?</h2>
151<p>A path on the main domain would be a shorter address, but it wouldn&#x27;t work. The iOS app claims <code>pokernexus.com</code> as a universal link domain, and its <code>apple-app-site-association</code> file claims every path (<code>&quot;/&quot;: &quot;*&quot;</code>), because the app and the website share one route tree. Android&#x27;s verified app links do the same for the apex host.</p>
152<p>That means an installed app intercepts every link to the main domain. A <code>/app</code> path there would open the app on exactly the phones that already have it, and the redirect would only ever run on phones that don&#x27;t. Worse, it would put device sniffing in front of the entire website, where a bug could send every visitor to the App Store.</p>
153<p><code>go.pokernexus.com</code> is deliberately unclaimed. The iOS entitlement lists only <code>applinks:pokernexus.com</code>, and since Android fetches <code>assetlinks.json</code> per host, the shortlink host simply serves none. Neither platform intercepts it. Running it as its own service also turns &quot;never serve the app on that host&quot; from a rule someone has to remember into a property of the deployment. The service imports no workspace packages, so its Docker image is small and it deploys on its own schedule. Repointing a link is a one-line change that doesn&#x27;t rebuild the website.</p>
154<p>If the app is already installed, <code>/app</code> still goes to the store listing, where the OS shows an &quot;Open&quot; button instead of &quot;Get.&quot; Sending someone to the store and then into a specific screen after install (deferred deep linking) is a different feature, and not one this host tries to provide.</p>
155<h2>Test before you print</h2>
156<p>Unknown paths return a <code>404</code> rather than falling back to the website. A typo in a printed URL that silently lands on the homepage looks like it&#x27;s working, and the time to learn about that typo is while testing the QR code, not after a box of purchased cards arrives. The same reasoning is why <code>/App</code>, <code>/apps</code>, and <code>/app/extra</code> all return <code>404</code>, while a trailing slash is forgiven.</p>
157<p>The tests use real user agent strings rather than simplified ones. Besides the obvious phones and desktops, the fixtures include Instagram&#x27;s and Facebook&#x27;s in-app browsers, an Android tablet, Chrome and Safari on an iPad, Slack&#x27;s link expander, <code>curl</code>, and both Googlebot smartphone crawlers. Another test reads the App Store ID and Android package ID from the client app&#x27;s source files and asserts they match the constants in the shortlink service, so the two can&#x27;t drift apart.</p>
158<p>The result is a service small enough to read in one sitting, and one URL that works on the back of a card regardless of what&#x27;s pointed at it. If you want a QR code of your own, the <a href="/lab/qr-code-generator">generator</a>
158 is free to use, and nothing you enter leaves your browser.</p></div><div class="mt-12 border-gray-200 border-t pt-8 dark:border-gray-700"><h2 class="mb-4 font-semibold text-lg">Comments</h2></div></div></section></div>
158<script></script>
158</div>
158<script>$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;b<a.length;b+=2){var c=a[b],e=a[b+1];null!==e.parentNode&&e.parentNode.removeChild(e);var f=c.parentNode;if(f){var g=c.previousSibling,h=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d||"/&"===d)if(0===h)break;else h--;else"$"!==d&&"$?"!==d&&"$~"!==d&&"$!"!==d&&"&"!==d||h++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;e.firstChild;)f.insertBefore(e.firstChild,c);g.data="$";g._reactRetry&&requestAnimationFrame(g._reactRetry)}}a.length=0};
159$RC=function(a,b){if(b=document.getElementById(b))(a=document.getElementById(a))?(a.previousSibling.data="$~",$RB.push(a,b),2===$RB.length&&("number"!==typeof $RT?requestAnimationFrame($RV.bind(null,$RB)):(a=performance.now(),setTimeout($RV.bind(null,$RB),2300>a&&2E3<a?2300-a:$RT+300-a)))):b.parentNode.removeChild(b)};$RC("B:0","S:0")</script>
159</body></html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.