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'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'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'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 & 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='http://www.w3.org/2000/svg'%20version='1.1'%20viewBox='0%200%2032%2032'
1%3e%3cdefs%3e%3c!--%20Define%20mask%20for%20center%20hole%20--%3e%3cmask%20id='center-hole'%3e%3crect%20x='0'%20y='0'%20width='32'%20height='32'%20fill='white'%20/%3e%3ccircle%20cx='16'%20cy='16'%20r='3'%20fill='black'%20/%3e%3c/mask%3e%3c!--%20Define%20circle%20masks%20for%20each%20segment%20--%3e%3cmask%20id='circle-mask-0'%3e%3crect%20x='0'%20y='0'%20width='32'%20height='32'%20fill='white'%20/%3e%3ccircle%20cx='22.47213595499958'%20cy='11.297717981660215'%20r='8'%20fill='black'%20/%3e%3c/mask%3e%3cmask%20id='circle-mask-1'%3e%3crect%20x='0'%20y='0'%20width='32'%20height='32'%20fill='white'%20/%3e%3ccircle%20cx='22.47213595499958'%20cy='20.702282018339787'%20r='8'%20fill='black'%20/%3e%3c/mask%3e%3cmask%20id='circle-mask-2'%3e%3crect%20x='0'%20y='0'%20width='32'%20height='32'%20fill='white'%20/%3e%3ccircle%20cx='13.52786404500042'%20cy='23.60845213036123'%20r='8'%20fill='black'%20/%3e%3c/mask%3e%3cmask%20id='circle-mask-3'%3e%3crect%20x='0'%20y='0'%20width='32'%20height='32'%20fill='white'%20/%3e%3ccircle%20cx='8'%20cy='16'%20r='8'%20fill='black'%20/%3e%3c/mask%3e%3cmask%20id='circle-mask-4'%3e%3crect%20x='0'%20y='0'%20width='32'%20height='32'%20fill='white'%20/%3e%3ccircle%20cx='13.52786404500042'%20cy='8.39154786963877'%20r='8'%20fill='black'%20/%3e%3c/mask%3e%3c/defs%3e%3cg%20mask='url(%23center-hole)'%3e%3c!--%20Red%20--%3e%3cg%20mask='url(%23circle-mask-0)'%3e%3ccircle%20cx='22.47213595499958'%20cy='20.702282018339787'%20r='8'%20fill='%23EF4444'%20/%3e%3c/g%3e%3c!--%20Orange%20--%3e%3cg%20mask='url(%23circle-mask-1)'%3e%3ccircle%20cx='13.52786404500042'%20cy='23.60845213036123'%20r='8'%20fill='%23F59E0B'%20/%3e%3c/g%3e%3c!--%20Green%20--%3e%3cg%20mask='url(%23circle-mask-2)'%3e%3ccircle%20cx='8'%20cy='16'%20r='8'%20fill='%2322C55E'%20/%3e%3c/g%3e%3c!--%20Cyan%20--%3e%3cg%20mask='url(%23circle-mask-3)'%3e%3ccircle%20cx='13.52786404500042'%20cy='8.39154786963877'%20r='8'%20fill='%2306B6D4'%20/%3e%3c/g%3e%3c!--%20Purple%20--%3e%3cg%20mask='url(%23circle-mask-4)'%3e%3ccircle%20cx='22.47213595499958'%20cy='11.297717981660213'%20r='8'%20fill='%238B5CF6'%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 "a separate shortlink host for printed links." This post covers what that host does and why it'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 "Download." The trouble is that a QR code encodes exactly one URL, while "download the app" 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't identify) should land on <a href="https://pokernexus.com">the website</a>.</li> 7</ul> 8<p>Printed cards also can't be updated once they're in someone'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: "forward" | "drop"; 17} 18 19const WEBSITE: Destination = { 20 url: "https://pokernexus.com", 21 query: "forward", 22}; 23 24const APP_STORE: Destination = { 25 url: "https://apps.apple.com/app/id6800184564", 26 query: "drop", 27}; 28 29const PLAY_STORE: Destination = { 30 url: "https://play.google.com/store/apps/details?id=com.pokernexus.app", 31 query: "drop", 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"> "forward"</span><span style="color:#D73A49;--shiki-dark:#F97583"> |</span><span style="color:#032F62;--shiki-dark:#9ECBFF"> "drop"</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">"https://pokernexus.com"</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">"forward"</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">"https://apps.apple.com/app/id6800184564"</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">"drop"</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">"https://play.google.com/store/apps/details?id=com.pokernexus.app"</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">"drop"</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'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) => 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">=></span><span style="color:#6F42C1;--shiki-dark:#B392F0"> Destination</span><span style="color:#24292E;--shiki-dark:#E1E4E8">);</span></span></code></pre> 54<p>It'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<Record<string, Target>> = { 56 "/": { ...WEBSITE, query: "drop" }, 57 "/app": (request) => storeFor(request.headers.get("user-agent")), 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"><</span><span style="color:#6F42C1;--shiki-dark:#B392F0">Record</span><span style="color:#24292E;--shiki-dark:#E1E4E8"><</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">>> </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"> "/"</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">"drop"</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> },</span></span> 60<span class="line"><span style="color:#032F62;--shiki-dark:#9ECBFF"> "/app"</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">=></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">"user-agent"</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 => { 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"> =></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'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't run the app.</p> 101<h3>Bots are checked first</h3> 102<p>The order of those checks is easy to overlook. Google's smartphone crawlers identify as phones and append their own name, so the Android crawler'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'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'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'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: "Android"</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't send them, though, so there'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'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'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(["GET", "HEAD"], "/*", (c) => { 117 const destination = resolve(c.req.path, c.req.raw); 118 if (destination === null) { 119 return c.notFound(); 120 } 121 c.header("Vary", "User-Agent"); 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">"GET"</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#032F62;--shiki-dark:#9ECBFF">"HEAD"</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], </span><span style="color:#032F62;--shiki-dark:#9ECBFF">"/*"</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">=></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">"Vary"</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#032F62;--shiki-dark:#9ECBFF">"User-Agent"</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'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'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'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&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 => { 137 const url = new URL(destination.url); 138 if (destination.query === "forward") { 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"> =></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"> "forward"</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't optional. The Play listing'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>"drop"</code> so that can'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'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>"/": "*"</code>), because the app and the website share one route tree. Android'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'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 "never serve the app on that host" 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'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 "Open" button instead of "Get." 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'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's and Facebook's in-app browsers, an Android tablet, Chrome and Safari on an iPad, Slack'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's source files and asserts they match the constants in the shortlink service, so the two can'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'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.