One Link, Three Destinations: An App-Install Funnel Without a Deep-Link SDK
The Android app went live, so the job looked trivial: add a Google Play button next to the App Store button and move on. A day later the real shape of the work was clear. The buttons were five minutes. Everything around them — which button to show where, what happens when you tap a share link on a phone versus a laptop, and how to pitch the native app to a web player without insulting the ones already using it — was the actual feature.
We did all of it without a deep-linking SDK like Branch or AppsFlyer. Those are the standard answer for install attribution and deferred deep links, and they’re genuinely good. They’re also an SDK in your app, a vendor dependency, and a paid tier the moment you grow. For routing-to-the-right-store plus basic attribution, a few pure functions and one install-referrer parameter cover it. Here’s what we built across the marketing site and the web game, and the three places reality refused to match the plan.
Three surfaces, one decision tree
The funnel has three entry points:
- The marketing site — every CTA shows the right store badge for the visitor’s device.
- A shareable
/applink — one URL we can paste anywhere that sends each visitor to their store. - The in-game install bar — mobile-web players of the puzzle game get nudged toward the native app.
All three answer the same question — what platform is this visitor on? — so the temptation is to write that logic once and import it everywhere. That mostly worked, with one wrinkle worth the whole section below.
The device check that has to run twice
The classifier itself is boring and pure, which is the point — it takes inputs, returns a verdict, and has unit tests:
export type DeviceClass = 'ios' | 'android' | 'web';
export function detectDeviceClass(opts: {
ua: string;
platform: string;
maxTouchPoints: number;
}): DeviceClass {
const { ua, platform, maxTouchPoints } = opts;
const isIPhone = /iPhone|iPod/.test(ua);
// iPadOS 13+ reports as MacIntel Safari; detect via multi-touch.
const isIPad = /iPad/.test(ua) || (platform === 'MacIntel' && maxTouchPoints > 1);
if (isIPhone || isIPad) return 'ios';
if (/Android/.test(ua)) return 'android';
return 'web';
}
That iPad line is the one that bites everyone. Since iPadOS 13, Safari on an iPad reports itself as MacIntel with a desktop user-agent — Apple did it so iPads would get desktop websites. The cost is that naive UA sniffing classifies every iPad as a Mac and shows it the desktop CTA instead of the App Store badge. The tell is maxTouchPoints > 1: a real Mac trackpad reports 0, an iPad reports 5. Our unit tests pin exactly this — a MacIntel with touch is ios, a MacIntel without touch is web.
The wrinkle: this function can’t be the thing that runs in the browser. The marketing site is Astro, and the detection has to happen in an inline head script so the right CTA is chosen before first paint — no flash of the wrong button. Astro inline scripts (is:inline) aren’t bundled, so they can’t import from a module. So the logic lives in two places: the tested detectDeviceClass function, and a hand-mirrored copy inside the inline script. That duplication is deliberate and commented in both spots, because the alternative — a visible CTA flicker on every page load — is worse. The CSS then shows exactly one of three lanes:
/* Default (desktop): only .cta-web shows */
.cta-ios, .cta-android { display: none !important; }
html.ua-ios .cta-web { display: none !important; }
html.ua-ios .cta-ios { display: revert !important; }
html.ua-android .cta-web { display: none !important; }
html.ua-android .cta-android { display: revert !important; }
On Android we show the Play badge only — no “play in the browser” link. That was a product call: if a native app exists, send mobile users to it, don’t give them a web off-ramp.
The share link that redirects but stays crawlable
/app is the link we paste into a tweet, a Pinterest pin, a YouTube description. Tap it on an iPhone, land on the App Store. Tap it on a Pixel, land on Google Play. Open it on a laptop, get both badges and a QR code to scan with your phone.
The redirect runs in a pre-paint inline script — but with a guard that turns out to be load-bearing:
<script is:inline define:vars={{ ios: APP_STORE_URL, android: PLAY_STORE_URL }}>
(function () {
var ua = navigator.userAgent;
if (/bot|crawl|spider|slurp|Googlebot|bingbot/i.test(ua)) return; // keep /app indexable
var isIPhone = /iPhone|iPod/.test(ua);
var isIPad = /iPad/.test(ua) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (isIPhone || isIPad) { location.replace(ios); return; }
if (/Android/.test(ua)) { location.replace(android); return; }
// desktop/other: stay and show badges + QR
})();
</script>
Without the bot guard, Googlebot follows the redirect and concludes /app is just a doorway to the Play Store, so it never indexes the page or the content under it. With the guard, crawlers get the full rendered page — both badges, the QR, real text — while humans on phones get bounced straight to their store. Same URL, two behaviors, and the share link keeps its SEO value.
The desktop fallback had its own small fight. The store badges carry the same .cta-ios / .cta-android classes the global CSS hides by default, so on /app both badges were invisible until I overrode the hide with an ID-scoped rule — #app-badges .cta-ios beats a bare .cta-ios on specificity even with both marked !important. A reminder that !important doesn’t win ties; specificity still decides.
The banner that must not nag people who already installed
This is the one with teeth. The puzzle game runs at a public URL and inside the native app as a WebView. If the in-game “Get the app!” bar naively renders for every mobile visitor, it shows up inside the native app too — telling people who already installed the app to go install the app. Embarrassing, and a trust killer.
So the banner’s first job is to detect that it’s running inside its own app’s WebView and render nothing:
export function isInWebView(): boolean {
if (typeof window === 'undefined') return false;
const hasRnBridge = !!(window as any).ReactNativeWebView;
const uaMatches =
typeof navigator !== 'undefined' && /PuzzleGameApp/.test(navigator.userAgent);
return hasRnBridge || uaMatches;
}
Two signals, because either alone can be absent: the React Native bridge object is injected when the page loads in the shell, and the app appends a PuzzleGameApp token to the user-agent. If either is present, the bar stays gone. The whole show/hide decision is a pure function the WebView check feeds into, so “never show inside the app” is a single tested branch rather than scattered conditionals:
export function shouldShowOnLoad(i: LoadInput): boolean {
if (i.isWebView) return false; // already in the app — never
if (i.platform !== 'android') return false;
return !i.dismissedThisSession;
}
iOS doesn’t get a custom bar at all. Safari has a native Smart App Banner that you summon with a single meta tag — <meta name="apple-itunes-app" content="app-id=…"> — and it sits at the top of the page, looks native, costs nothing, and is inert inside a WebView. So iOS gets Apple’s banner, Android gets our custom one, and the custom bar’s code path is Android-only. (We pin our bar to the top too, to match where users expect an install banner to live.)
Attribution without the SDK
The reason teams reach for Branch or AppsFlyer is attribution — knowing the install came from this banner versus that tweet. You can get the useful 80% of that for free. Two pieces:
Click tracking is just an analytics event on the surfaces themselves — every CTA fires a cta_click with a source/target, and the in-game bar fires install_banner with shown / clicked / dismissed plus what triggered it. That tells you the funnel’s top.
For the install itself, Google Play reads a referrer parameter off the store URL and surfaces it in the Play Console acquisition reports (and hands it to the app via the Install Referrer API):
const PLAY_URL = `${PLAY_STORE_URL}&referrer=${encodeURIComponent(
"utm_source=puzzle_web&utm_medium=install_banner&utm_campaign=in_app",
)}`;
Now installs driven by the in-game bar show up tagged in the Console, distinct from the marketing site, distinct from organic. No SDK, no deferred deep link (we genuinely don’t need to drop a user onto a specific puzzle post-install), and no vendor in the critical path. If we ever need deferred deep linking — open the exact shared puzzle after install — that’s the day to add Branch. Not before.
What I’d do differently
Write the device check as a pure function from the first line. The version of this that ages badly is navigator.userAgent checks sprinkled across components. Pulling it into one tested function with explicit inputs meant the iPad-pretends-to-be-a-Mac case got a name and a test instead of being a field bug six weeks later.
Decide the “no web off-ramp on mobile” question before building the CTAs, not after. Whether Android users get a Play badge only or a Play badge plus a web link changes the CSS lanes and the copy. We flip-flopped once; deciding the funnel’s intent first would have saved a round.
Treat the WebView suppression as the headline requirement, not a footnote. It’s tempting to build the happy-path banner first and bolt on “oh, and hide it in the app” later. Inverting that — making “never show inside the app” the first test that passes — is what kept an embarrassing bug out of production. The funnel’s whole credibility rests on not pestering the people who already said yes.
The badges were the part anyone could see. The part that mattered was the routing underneath them — three surfaces, one honest answer to “where should this person go,” and the discipline to not show the install pitch to someone holding the installed app.