Deep linking is one of those features that looks trivial in a sprint planning meeting and then quietly burns a day across three different teams.
The concept is simple: a user taps a link, and instead of opening a browser, it opens your native app directly to the right screen. iOS calls these Universal Links. Android calls them App Links. Both require your server to host a verification manifest at a well-known URL so the operating system can confirm your app is authorized to handle links for your domain.
The server-side piece is the easiest part of the whole deep linking chain, but it still has a couple of non-obvious gotchas that will cost you debugging time if you don’t know about them. This post covers how to set it up in Phoenix so your mobile teams can focus on the client-side integration without waiting on you.
Why this breaks across teams#
Deep linking requires coordination between three surfaces: the iOS app, the Android app, and the server. Each platform has its own manifest format, its own validation rules, and its own way of failing silently when something is wrong.
The server team usually gets pulled in last, right when someone on the mobile side says “it’s not working and we think it’s a server issue.” Having the manifests set up correctly from the start, with validation confirmed, removes the server from the debugging chain entirely. That’s the goal here.
Create the manifest files#
Both iOS and Android expect their manifests to be served from a /.well-known/ path on your domain. Create a directory for them in your Phoenix project:
mkdir -p priv/static/well-knownI name this well-known (without the leading dot) so the directory isn’t hidden when browsing the project source. The leading dot in the URL path gets handled by the Plug configuration below.
iOS: apple-app-site-association#
Create priv/static/well-known/apple-app-site-association (no file extension):
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.example.myapp",
"paths": ["/r/*"]
},
{
"appID": "TEAM_ID.com.example.myapp-dev",
"paths": ["/r/*"]
}
]
}
}Replace TEAM_ID with your Apple Developer Team ID and update the bundle identifiers and paths to match your app. The paths array controls which URL patterns trigger the app to open. Here I’m using /r/* as a prefix for redirect-style deep links, but yours will depend on your routing scheme.
Including a -dev entry lets your development builds handle deep links too, which saves your iOS team from constantly switching between builds during testing.
Android: assetlinks.json#
Create priv/static/well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}
]Replace the package_name and sha256_cert_fingerprints with your app’s values. You can get the fingerprint from your signing keystore using keytool -list -v -keystore your-keystore.jks.
Don’t forget to commit#
By default, Phoenix’s .gitignore excludes priv/static. You’ll need to force-add this directory:
git add -f priv/static/well-knownAlternatively, put the files in a different directory outside priv/static and adjust the Plug configuration below. Either works.
Serve the manifests with Plug.Static#
Add a second Plug.Static call in your endpoint, below the existing one:
# lib/my_app_web/endpoint.ex
plug Plug.Static,
at: "/.well-known",
from: {:my_app, "priv/static/well-known"},
gzip: false,
content_types: %{"apple-app-site-association" => "application/json"}There are two things worth noting here.
The content_types map is not optional for iOS. Without it, Phoenix serves the apple-app-site-association file as text/plain because it has no file extension. iOS requires the Content-Type to be application/json or it silently ignores the file. This is the gotcha that will cost you an afternoon if you don’t know about it. Android’s assetlinks.json has a .json extension, so Plug handles its content type automatically.
The at: "/.well-known" path maps to the well-known directory (no dot) on disk. This is why we named the directory without the leading dot; it keeps the files visible in your project while serving them at the correct URL path.
Verify before you ship#
Don’t wait for your mobile team to tell you something is broken. Validate both manifests before you merge:
- Android: Google’s Digital Asset Links tool checks that
assetlinks.jsonis served correctly and that the fingerprint matches. - iOS: Branch’s AASA Validator confirms that
apple-app-site-associationis served with the right content type and valid JSON structure.
Run both validators against your staging environment after deploy. They catch the silent failures (wrong content type, malformed JSON, unreachable URL) that neither your server logs nor your mobile team’s debugger will surface clearly.
The coordination takeaway#
The technical setup here is straightforward. The reason deep linking burns time isn’t complexity; it’s that the failure modes are silent and the ownership is split across teams. When the server manifest is wrong, iOS just doesn’t open the app. No error. No log. The mobile developer files a bug, the server developer says “the file is there,” and everyone spends an afternoon before someone checks the Content-Type header.
Set this up once, validate it with the tools above, and your side of the deep linking chain is solid. Your mobile teams can debug their integration knowing the server isn’t the variable.
