Firebase Dynamic Links is a nice way to go from a short link, to a deep link in an app, with or without the app already installed (aka Deferred Deep Link).
The one problem is, Google never maintained it well, and as of August 2025, it's going away for good.
This sounds manageable - just move to another service. Problem is: there isn't really another service available. Not in a 1:1 mapping anyway, at least how we use it at Tend - and how we use it is pretty basic.
So we've built our own.
What we need, and why Google dropped the ball
Our usage is more application deployment than marketing. We need to handle the following situations:
- The user has the app already, hits a short link (eg https://link.tend.nz/abc) which loads the app, resolves the short link into something with a bit more info (possibly an affiliate association or an acquisition code), and our code uses that.
- Same as above, but the link doesn't need to be resolved. It might be something like https://link.tend.nz/book which would take you to the booking page.
- The user does not have the app installed, they hit a link, go the App Store or Play Store, install the app, run it, and the short link is then passed into the newly installed app. From there it's as above.
The first two here are easy, or well supported by both OS's. The deferred installation, keeping the referrer / metadata context, thats where the secret sauce is. Except, it's not that secret.
I said before that Google "never maintained Firebase Links well". We've been using it for 5 years, and its been around longer than that.
- No customisation of the landing page
- Copies the payload to the clipboard on iOS which hasn't been needed since iOS6
- ... Android works kinda well? Sort of. I think. It's ugly, but it was reliable.
Just another #killed-by-google service, I guess. Clearly no one was getting promoted by working on it.
The Market
We looked around for an off the shelf solution, but all we could find was marketing software (Branch and others) which are more concerned with click attribution (and insanely high per-click costs); or projects which look they were spun up by one or two developers, and I'd expect them to not be there in 12 months.
We might have been able to put up with Branch, for example, but we'd be using a tiny feature in a massive suite, and even then - some of the limitations were very off-putting, not to mention installing their SDK, which because its marketing/tracking, I assume it's going to inhale everything off the device it can, which we don't want.
Our Solution
In "doing it ourselves" we had to change the app up a bit. Note that our app is on iOS and Android, and uses React Native. Some of this you might not need to do.
Universal Links and App Links
We didn't have Universal Links (iOS) or App Links (Android) in the app, at least not properly setup. Most of what's below is just normal app setup for this - nothing overly special. Apple and Google document it fairly well in their developer portals.
iOS
We set up Universal Links, by adding this into the entitlements file
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:link.tend.nz</string>
</array>
We have a different link in the DevDebug and DevRelease entitlements file.
We also have to put data in .well-known/apple-app-site-association
on that domain, over https. For this we use Cloudfront, API Gateway and a Lambda to serve it, but it would work with any hosting. More on that below.
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [
"A8CXRQLN7L.nz.tend.app"
],
"components": [
{
"/": "*"
}
]
}
]
}
}
And finally, in the AppDelegate.swift we added some calls into RCTLinkingManager
as we are using react-navigation
override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
return RCTLinkingManager.application(app, open: url, options: options)
}
override func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
Android
For App Links, we added an intent filter to the main activity
<activity
android:name=".MainActivity"
android:label="@string/app_name"
...
>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.tendapp" />
<data android:scheme="tend" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="link.tend.nz" />
</intent-filter>
</activity>
And served the associated .well-known/assetlinks.json
file
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.tendapp",
"sha256_cert_fingerprints": [
"68:D0:8B:4E:9B:FA:A1:C3:24:XX:XX:XX:11:B0"
]
}
}
]
You get the SHA fingerprint when the app is installed, as its based on your signing key. You can verify and reverify the linking using
# recheck the links without reinstalling
adb shell pm verify-app-links --re-verify com.tendapp
# show the current state
adb shell pm get-app-links com.tendapp
com.tendapp:
ID: 01234567-89ab-cdef-0123-456789abcdef
Signatures: [***]
Domain verification state:
link.tend.nz: verified
broken.domain.com: 1024
Anything with a numeric code is an error specific to your device and needs fixing.
React Native
We use react-navigation
for internal navigation, which hooks into the normal Linking
class in React Native. They have good documentation on setup here.
We have a single hook which handles the inbound link
import {Linking} from 'react-native';
export const useDynamicLinks = () => {
const handleDynamicLink = async (event: {url: string; source: string}) => {
//work out what to do with "url"
// this is up to your app
};
// Dynamic link is tapped when app is foregrounded
useEffect(() => {
const subscription = Linking.addEventListener('url', async (event: {url: string}) => {
return await handleDynamicLink({
url: event.url,
source: 'rnlinking-urllistener',
});
});
return () => {
subscription();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Dynamic link is tapped when app is not yet installed, not running, or backgrounded
useEffect(() => {
void (async () => {
const initialLink = await Linking.getInitialURL();
if (initialLink !== null) {
const event = {
url: initialLink,
source: 'rnlinking-getinitialurl',
};
await handleDynamicLink(event);
}
})();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
};
The backend
We have a basic backend setup, which looks roughly like this:
The Cloudfront is mostly our of the box - short expiry, esp for the .well-known
links, and no caching on the links themselves.
API Gateway just passes things to Lambda.
The lambda is a single entry point which serves the page based on the input url
const dynamicLinksHandler: APIGatewayProxyHandlerV2 = async event => {
const {pathParameters, queryStringParameters} = event;
const proxyValue = pathParameters && pathParameters['proxy'];
if (proxyValue === undefined) {
return await Promise.resolve({statusCode: 404, body: 'Wut?'});
}
if (proxyValue === 'unroll') {
return unrollUrl(event);
}
if (proxyValue === '.well-known/apple-app-site-association') {
return appleSiteAssociation();
}
if (proxyValue === '.well-known/assetlinks.json') {
return androidSiteAssociation();
}
const renderedPage = renderPage(proxyValue, removeUndefinedItems(queryStringParameters ?? {}));
if (renderedPage) {
return {
statusCode: 200,
body: renderedPage,
headers: {
'content-type': 'text/html',
},
};
}
return {
statusCode: 404,
body: 'yeah, IDK',
headers: {
'content-type': 'text/html',
},
};
};
/unroll
converts a short url - /aBcD
into a longer one that the app can use, eg /add-affiliate?affiliateId=pmc
with a basic REST / JSON result.
The two site associations just return the json above.
The render page uses handlebars to render a page. The page it just HTML, but with the following:
iOS
We use the provided (since iOS6!) process to keep a referrer link after App Store install. It's easy, as it's just a meta header.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="apple-itunes-app"
content="app-id=12345678, app-argument=https://link.tend.nz/add-affiliate?affiliateId=pmc"
/>
This adds a banner to the top of the page, which allows the user to install the app (or open it).
We don't have any control over that banner, but we do over the rest of the page.
The old (Firebase) method was to copy the URL onto the clipboard, then bounce the user to the appstore. When they opened the app, it'd check the clipboard (which now prompts the user, confusingly) and get the URL back.
This isn't needed, and it confuses users who are now used to pressing NO on pretty much everything.
Android
For android we need to do a bit of special link generation. We need to make a Intent
link which looks like this, but on one line: (assuming the target link is https://link.tend.nz/add-affiliate?affiliateId=pmc
)
intent://add-affiliate?affiliateId=pmc
#Intent;
scheme=https;
package=com.tendapp;
action=android.intent.action.VIEW;
category=android.intent.category.DEFAULT;
category=android.intent.category.BROWSABLE;
S.browser_fallback_url=https://play.google.com/store/apps/details?id=com.tendapp&referrer=https://link.tend.nz/add-affiliate?affiliateId=pmc;
end
I use javascript to build this, and when the user hits a button, it loads that into window.location.href
. If the user has the app, it'll be opened with the https://link.tend.nz/add-affiliate?affiliateId=pmc
link passed in, and if they don't, they'll be taken to the Play Store to install it, press "Continue" and then the url in referrer
link is passed into the app.
A bunch of this came from here, tho I've changed it substantially.
Testing
Testing this is, frankly, a pain in the arse. The deferred install only works once the app is IN the App Store and published, which is often too late to change things. It doesn't work with App Tester or Test Flight, at least not very well. Both Apple and Google could do a lot, lot better here.
Also:
- If you change the association document, you have to delete and reinstall the app on iOS, as it's only checked on install. Android might be the same, but the
adb
commands above can reset it. - iOS has a debug menu in Settings -> Developer -> Universal Links which allows you to test a URL out
- You can test the link launching in the iOS sim using
xcrun simctl openurl booted “https://link.tend.nz/the-link"
- I'm yet to find a way to do this reliably in Android - All of this must be served over https. I found it useful to use Tailscale funnel for this, at least initially, tho you can't fake the certificate.
And once you have it up and running, you need some HTML/CSS skills to make the interstitial page pretty.
Overall, while the App Links and Universal Links part is well documented, the deferred deep linking aspect is a poorly documented mess, but it does work. Now we have to wait until we are a few app versions down the track so we can use it properly.