<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yury Hryshchanka</title>
    <description>The latest articles on DEV Community by Yury Hryshchanka (@yhryshchanka).</description>
    <link>https://dev.to/yhryshchanka</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4185470%2F5a889d48-6b85-4365-b09b-68315fa309f8.jpg</url>
      <title>DEV Community: Yury Hryshchanka</title>
      <link>https://dev.to/yhryshchanka</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yhryshchanka"/>
    <language>en</language>
    <item>
      <title>Opening the app from a link, and what happens when it is not installed</title>
      <dc:creator>Yury Hryshchanka</dc:creator>
      <pubDate>Wed, 14 Oct 2026 21:12:39 +0000</pubDate>
      <link>https://dev.to/yhryshchanka/opening-the-app-from-a-link-and-what-happens-when-it-is-not-installed-1505</link>
      <guid>https://dev.to/yhryshchanka/opening-the-app-from-a-link-and-what-happens-when-it-is-not-installed-1505</guid>
      <description>&lt;p&gt;A link on the web should open the app on the exact screen that the link points&lt;br&gt;
to. Sometimes the app is not installed. In that case the link should do&lt;br&gt;
something better than showing the person a page that only says the app exists.&lt;/p&gt;

&lt;p&gt;The second part is the hard one. The browser will not tell you whether an app&lt;br&gt;
is installed, and it should not: a page that could list your installed apps&lt;br&gt;
would be a fingerprinting tool.&lt;/p&gt;

&lt;p&gt;Here is an example that runs in production. Open it on a phone and not on a&lt;br&gt;
laptop:&lt;br&gt;
&lt;a href="https://nicegram.me/hub/channel/durov" rel="noopener noreferrer"&gt;Pavel Durov's channel in the Nicegram catalogue&lt;/a&gt;.&lt;br&gt;
The &lt;code&gt;Open&lt;/code&gt; button on that page is the subject of this article.&lt;/p&gt;

&lt;p&gt;Nicegram is a Telegram client, so the fallback here goes to Telegram itself. If&lt;br&gt;
the app is installed, the phone opens Nicegram. If the app is not installed, the&lt;br&gt;
same tap opens Telegram on the web, and it carries the channel you came from.&lt;br&gt;
There is one URL behind that button and there are two results. None of my code&lt;br&gt;
chose between those two results.&lt;/p&gt;
&lt;h2&gt;
  
  
  The guess this replaced
&lt;/h2&gt;

&lt;p&gt;Before the server was involved in any of this, the page tried to work it out&lt;br&gt;
alone. I inherited a version that was built from&lt;br&gt;
&lt;a href="https://stackoverflow.com/a/49699177" rel="noopener noreferrer"&gt;this Stack Overflow answer&lt;/a&gt;. It was&lt;br&gt;
copied so closely that even the variable name was the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://play.google.com/store/apps/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;appname://&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That answer is from April 2018 and its score is minus one. That is where code&lt;br&gt;
that ran in production for years actually came from. I prefer to write this down&lt;br&gt;
rather than suggest that the code came from a specification.&lt;/p&gt;

&lt;p&gt;The idea works like this. The page navigates to a custom URL scheme. A custom&lt;br&gt;
URL scheme is an address like &lt;code&gt;appname://&lt;/code&gt; that an app registers for itself when&lt;br&gt;
it is installed. Then the page starts a timer. If the app takes the navigation,&lt;br&gt;
the browser suspends the page, which means the page stops running its code. So&lt;br&gt;
when the timer finally runs, it runs late and does nothing. If the page is still&lt;br&gt;
running and the timer fires on schedule, the code assumes there is no app and&lt;br&gt;
goes to the store page instead.&lt;/p&gt;

&lt;p&gt;The timer is twenty-five milliseconds and the threshold is one hundred&lt;br&gt;
milliseconds. Both numbers are guesses about how fast a phone switches between&lt;br&gt;
apps. Somebody measured them on one device, one time. No browser ever agreed to&lt;br&gt;
respect either number. A busy phone can need more than one hundred milliseconds&lt;br&gt;
even when the app is installed, and then the timer decides the app is not there.&lt;/p&gt;

&lt;p&gt;Look at what the elapsed-time check really reads. The check does not ask whether&lt;br&gt;
the app exists. The check asks whether the page stopped running, and it treats&lt;br&gt;
that as the same question. The scheme fires, focus leaves for the app, the page&lt;br&gt;
freezes, the timer runs late, and nothing happens. That is the happy path, and&lt;br&gt;
it is built on a promise that no browser ever made.&lt;/p&gt;

&lt;p&gt;So it does not always go that way. Some browsers keep the timer running while&lt;br&gt;
the app is starting. Then the check passes, the page navigates to the store, and&lt;br&gt;
the app opens as well. Both things happen. The person is now inside the app, and&lt;br&gt;
whatever they do there, the browser is sitting on a page that the person never&lt;br&gt;
chose, waiting for them to come back to it.&lt;/p&gt;

&lt;p&gt;It gets worse when the person comes back. Imagine they return to the browser one&lt;br&gt;
minute later. Far more than one hundred milliseconds have passed, so the same&lt;br&gt;
code now reads that delay as proof that the app took the link. The story that&lt;br&gt;
the page tells about what happened depends on how long somebody spent somewhere&lt;br&gt;
else.&lt;/p&gt;

&lt;p&gt;Then the platforms ended the argument. The Android answer with the highest score&lt;br&gt;
starts with a line that its own author went back and added years later:&lt;br&gt;
&lt;a href="https://stackoverflow.com/a/12572327" rel="noopener noreferrer"&gt;&lt;code&gt;UPDATE: Google broke this.&lt;/code&gt;&lt;/a&gt; On the Apple&lt;br&gt;
side, &lt;a href="https://stackoverflow.com/a/41728904" rel="noopener noreferrer"&gt;an answer from January 2017&lt;/a&gt; says it&lt;br&gt;
plainly. The behaviour changed in iOS 9.2 in order to push developers onto&lt;br&gt;
universal links. None of this was ever a contract that you could look up. It was&lt;br&gt;
only behaviour, and behaviour moved.&lt;/p&gt;
&lt;h2&gt;
  
  
  Give the decision away
&lt;/h2&gt;

&lt;p&gt;Universal links on iOS and App Links on Android turn this around. A universal&lt;br&gt;
link, and an App Link on Android, is an ordinary &lt;code&gt;https://&lt;/code&gt; link that an app can&lt;br&gt;
claim as its own. At install time the app declares which URLs on your domain&lt;br&gt;
belong to it. After that the operating system owns the decision. The operating&lt;br&gt;
system is the one component in this story that actually knows what is installed,&lt;br&gt;
because the operating system did the installing.&lt;/p&gt;

&lt;p&gt;The setup is not what this article is about. There is a file that you host on&lt;br&gt;
your domain, and there is a declaration that the app ships with. The platform's&lt;br&gt;
own documentation covers both of them properly. The part that nobody writes down&lt;br&gt;
is what happens next.&lt;/p&gt;
&lt;h2&gt;
  
  
  One path, not the whole site
&lt;/h2&gt;

&lt;p&gt;The claim of the app does not have to cover your whole domain. Point the claim&lt;br&gt;
at a single path, and keep the real destination in a query parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UNIVERSAL_LINK_ORIGIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linkToApp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;UNIVERSAL_LINK_ORIGIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/deeplink?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the claim covers every path, then your pricing page and your support articles&lt;br&gt;
both become candidates for a handoff. A handoff means that the operating system&lt;br&gt;
opens the app instead of the page. Then you will spend a year explaining why a&lt;br&gt;
help page opened the app. A single path also gives you exactly one place on the&lt;br&gt;
server to put the fallback, which is where this article is going.&lt;/p&gt;
&lt;h2&gt;
  
  
  The link has to leave the site to work
&lt;/h2&gt;

&lt;p&gt;This is the rule that makes people think their setup is broken when the setup is&lt;br&gt;
fine.&lt;/p&gt;

&lt;p&gt;A universal link does not hand off when the person clicks it on a page that is&lt;br&gt;
already on the same domain. The browser reads that click as ordinary navigation&lt;br&gt;
inside the site that the browser is already showing, so the browser keeps the&lt;br&gt;
navigation. Your link is correct, your association is correct, and nothing&lt;br&gt;
happens.&lt;/p&gt;

&lt;p&gt;So the link has to point at a host that is technically different. The page sits&lt;br&gt;
on &lt;code&gt;example.com&lt;/code&gt; and the link goes to &lt;code&gt;www.example.com&lt;/code&gt;. For a reader these are&lt;br&gt;
the same site. For the check inside the browser they are different hosts. This&lt;br&gt;
is not a decision about canonical URLs, and it is not cosmetic.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;www&lt;/code&gt; is a convention here and not a requirement. The rule is about hosts, and&lt;br&gt;
any host you control will do as long as it is not the one serving the page.&lt;br&gt;
The deep link vendor Branch documents it, &lt;a href="https://stackoverflow.com/a/39926507" rel="noopener noreferrer"&gt;quoted in an answer from 2016&lt;/a&gt;,&lt;br&gt;
as a rule about crossing domains rather than about a particular name:&lt;br&gt;
a link on one host pointing at another host opens the app, and a link pointing&lt;br&gt;
at the same host does not. So the second host can be a dedicated subdomain that&lt;br&gt;
exists for nothing else, and what it serves when the app does not take the link&lt;br&gt;
is the subject of the rest of this article.&lt;/p&gt;

&lt;p&gt;The same source gives two more limits, and both of them are worth knowing before&lt;br&gt;
you spend a day looking for a bug you do not have. A link pasted into the&lt;br&gt;
address bar never opens the app. And a navigation started from JavaScript does&lt;br&gt;
not open the app either, unless it happens as part of a real user action. If you&lt;br&gt;
build the URL in code, the URL still has to be followed by a person clicking&lt;br&gt;
something.&lt;/p&gt;
&lt;h2&gt;
  
  
  A request that arrives is the answer
&lt;/h2&gt;

&lt;p&gt;I got here by turning the old code around. That code put the watching on the&lt;br&gt;
web side: fire the scheme, watch the clock, guess from whether the page was&lt;br&gt;
still alive. I wanted the opposite. Nothing of mine should watch anything, and&lt;br&gt;
the request should reach my server only when there is no app to take it. That&lt;br&gt;
moves the decision to the only place that can actually make it, which is the&lt;br&gt;
operating system.&lt;/p&gt;

&lt;p&gt;The browser is the program that makes the request. The browser also knows about&lt;br&gt;
the claim of the app, because the operating system recorded that claim. So the&lt;br&gt;
browser tries the app first. If the app is not installed, or if the app declines&lt;br&gt;
the handoff, the browser has nothing left to do except the thing that browsers&lt;br&gt;
do, which is to ask the server.&lt;/p&gt;

&lt;p&gt;This means that a request landing on my own server carries information. That&lt;br&gt;
request is the answer to the question I could not ask directly, and the answer is&lt;br&gt;
handed over by the only party that has it.&lt;/p&gt;

&lt;p&gt;I had no proof of that ordering when I wrote the branch. It was an assumption&lt;br&gt;
about how the chain is wired, and I built on the assumption anyway. The&lt;br&gt;
assumption held.&lt;/p&gt;

&lt;p&gt;The rest follows from there. When the claim fires, the request never leaves the&lt;br&gt;
phone. The operating system hands the URL to the app, and your server never&lt;br&gt;
hears about it. Silence is the success case. When the claim does not fire, the&lt;br&gt;
request arrives, and a request arriving at &lt;code&gt;/deeplink&lt;/code&gt; is the operating system&lt;br&gt;
telling you that it had nothing to hand this URL to. You never detect anything.&lt;br&gt;
You get told, and you get told by absence.&lt;/p&gt;

&lt;p&gt;That turns the whole problem into an ordinary server branch. The code below is&lt;br&gt;
Next.js 15, and in that framework the file has to be called &lt;code&gt;middleware.ts&lt;/code&gt; and&lt;br&gt;
has to sit at the root of the project. Nothing about the idea depends on the&lt;br&gt;
framework: any server that can answer one path before the router does will do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_ORIGINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://chat.example.net&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_ORIGINS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/deeplink&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolveTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/download&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note which of the two branches is a redirect and which is a rewrite, because&lt;br&gt;
they are not interchangeable here. A redirect tells the browser to go and&lt;br&gt;
request a different URL, and the address bar changes to that URL. A rewrite&lt;br&gt;
serves the content of another path from the same request, and the address bar&lt;br&gt;
does not change. The Telegram link has to be a redirect, because the&lt;br&gt;
destination is somebody else's site. The download page is a rewrite, so the&lt;br&gt;
person stays on &lt;code&gt;/deeplink&lt;/code&gt; and a reload does not bounce them anywhere.&lt;/p&gt;

&lt;p&gt;The allowlist in that code is not tidiness. The &lt;code&gt;?url=&lt;/code&gt; parameter is written by&lt;br&gt;
whoever shares the link, so redirecting to whatever it says publishes an open&lt;br&gt;
redirect on your own domain: a URL of yours that sends people wherever an&lt;br&gt;
attacker picks. Phishing campaigns are built out of exactly that, because the&lt;br&gt;
recognisable part of the address is your domain. So compare the parsed origin&lt;br&gt;
against a list of exact origins. A substring check on the hostname passes&lt;br&gt;
&lt;code&gt;https://chat.example.net.evil.test&lt;/code&gt;, and you will not notice until someone else&lt;br&gt;
does.&lt;/p&gt;

&lt;p&gt;Absence is strong evidence and it is not proof. Every condition from earlier in&lt;br&gt;
this article is also a way for the request to arrive from a phone that does have&lt;br&gt;
the app: the link pointing at the host the page is already on, the link pasted&lt;br&gt;
into the address bar, the navigation started by code rather than by a person.&lt;br&gt;
Your server sees the same request in all of those cases as it sees from somebody&lt;br&gt;
who has never installed anything. So pick a fallback that stays tolerable when&lt;br&gt;
you are wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where to send someone who does not have the app
&lt;/h2&gt;

&lt;p&gt;For a long time the fallback was the download page, with the store buttons on&lt;br&gt;
it. That is the obvious answer. The person has no app, so offer them the app.&lt;/p&gt;

&lt;p&gt;I changed it in July 2025, and the reasoning has held up better than I expected.&lt;br&gt;
Someone who taps a link to a conversation wants the conversation. A store page&lt;br&gt;
asks that person to install something first, and then to find their way back to a&lt;br&gt;
link they no longer have. Most people stop at the first step. The few people who&lt;br&gt;
finish arrive at an app that has no idea what link sent them.&lt;/p&gt;

&lt;p&gt;So the fallback sends them to the same conversation on the platform where that&lt;br&gt;
conversation already lives. They get what the link promised, on the web,&lt;br&gt;
immediately.&lt;/p&gt;

&lt;p&gt;The store page kept a job. The branch goes to the store page when the link&lt;br&gt;
carries no usable destination at all. A store page is a reasonable fallback when&lt;br&gt;
there is nothing at the other end of the link. A store page is a bad fallback&lt;br&gt;
when there is something there.&lt;/p&gt;

&lt;p&gt;Notice what this does when the guess is wrong. Somebody who does have the app&lt;br&gt;
gets sent to the same conversation on the web, which is where that person was&lt;br&gt;
going anyway. So being wrong costs almost nothing. That is luck, but it is the&lt;br&gt;
kind of luck you can arrange on purpose.&lt;/p&gt;
&lt;h2&gt;
  
  
  The chain, in order
&lt;/h2&gt;

&lt;p&gt;A page on &lt;code&gt;example.com&lt;/code&gt; wants to point at the conversation&lt;br&gt;
&lt;code&gt;https://chat.example.net/c/123&lt;/code&gt;, so it builds this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.example.com/deeplink?url=https%3A%2F%2Fchat.example.net%2Fc%2F123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walked from one end to the other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your code builds that URL on &lt;code&gt;www.example.com&lt;/code&gt;, a host the page is not
currently on, which is the whole reason &lt;code&gt;www&lt;/code&gt; is in there.&lt;/li&gt;
&lt;li&gt;The tap goes to the operating system, which checks the URL against the claims
it recorded when apps were installed.&lt;/li&gt;
&lt;li&gt;If a claim matches, the app opens and reads the same &lt;code&gt;?url=&lt;/code&gt; parameter to
work out which screen to show. Your server sees nothing.&lt;/li&gt;
&lt;li&gt;If no claim matches, the request reaches &lt;code&gt;/deeplink&lt;/code&gt;, your allowlist accepts
&lt;code&gt;https://chat.example.net&lt;/code&gt;, and the person is redirected to
&lt;code&gt;https://chat.example.net/c/123&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only the first step and the last step are yours. The two middle steps belong to&lt;br&gt;
the operating system and to the app. This design is worth copying because it&lt;br&gt;
stops pretending otherwise.&lt;/p&gt;

&lt;p&gt;The question that matters is not how to find out whether an app is installed.&lt;br&gt;
You cannot find that out, the platforms have decided that you should not, and&lt;br&gt;
every technique that claims otherwise is guessing. The question is what the&lt;br&gt;
person gets when the app is not there. That is a product decision in a technical&lt;br&gt;
costume, and it gets made by whoever writes the fallback branch, usually while&lt;br&gt;
that person is only trying to get a link to work.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
