Universal links not working in iOS

Please provide the following:

  1. SDK Version: 38 (managed)
  2. Platforms(Android/iOS/web/all): iOS

I am having trouble figuring out why deep linking with universal links is not working in my app.
The configuration on the server is correct, the AASA validator reports no issues.
However, the built application never registers on the device as an option for opening specified links. Not in Safari, not in Apple’s Notes app, or email clients (including Gmail and Apple’s Mail app).

Below is the configuration that I am using:

https://my.app.com/.well-known/apple-app-site-association

Configured as described in the expo universal links on iOS guide

{
	"applinks": {
		"apps": [],
		"details": [
			{
				"appID": "TEAMID.my.app.identifier",
				"paths": [
					"/users/login",
					"/users/activate"
				]
			}
		]
	}
}
app.json config

0 documentation available from expo, broken link to apple’s guide on the subject.
I assume that the short description “array that contains Associated Domains” resolves to the following:

{
    "ios": {
        "associatedDomains": [
            "applinks:my.app.com"
        ]
    }
}

(tried prefixing with https:// as well, did not work)

Does anyone identify any issue in the configurations listed above?
I have searched back and forth for possible issues, but I couldn’t find anything that would ring a bell.

Except for maybe, this:
https://developer.apple.com/documentation/safariservices/supporting_associated_domains

When searching for universal links on Apple’s website, I found the link above, which lists a very different configuration to be used in the server configuration. Following the structure described there, the apple-app-site-association configuration file should be as follows:

{
  "applinks": {
      "details": [
           {
             "appIDs": [ "TEAMID.com.app.my"],
             "components": [
               {
                  "/": "/users/login",
                  "comment": "For logging in"
               },
               {
                  "/": "/users/activate",
                  "comment": "For activating an account"
               },
               {
                  "#": "no_mobile",
                  "exclude": true,
                  "comment": "Optional marker to disable opening the link in the app"
               }
             ]
           }
       ]
   }
}

I have tried using this format as well, but this time, the AASA validator did not validate the configuration. And obviously, neither did it work in the app.

Does anyone have any idea how to actually configure universal links in expo for iOS?

1 Like

For anyone looking for a solution, prepare to read. The AASA configuration to place on the server in order to support the various iOS versions is not complicated per-se, but the documentation is definitely not easy to find.

For the expo team: Since the expo documentation lacks any specifics on how to configure the apple-app-site-association file, please, feel free to take this post, clean it, and use it for your documentation. It may be very useful to other developers who, like me, spent days figuring out what is what. You’re welcome :slight_smile:

NOTE: The following information may not be complete, accurate, correct, or any combination of these. But it’s the best I could collect from various help requests on Apple’s developer forums, Apple’s documentation, github, StackOverflow, or other websites. I will cite the sources which to me looked like the most reliable information, but those may not be the only places that the information comes from.

iOS 10 and and lower

Source: Adopting Handoff

I could only find limited information on this iOS version, and I also think that there are not enough users of iOS 10 (or lower) to justify the effort. But anyway, listing here for completeness.

iOS 10 requires the apple-app-site-association configuration to be placed either on the website root, or in the .well-known folder, with the latter being the preferred one to be checked:
https://<some-website.com>/.well-known/apple-app-site-association
https://<some-website.com>/apple-app-site-association

The file must be up to 128KB when uncompressed. The configuration file requires the following format:

{
	"activitycontinuation": {
		"apps": [
			"TEAMID.bundleidentifier",
			"OTHTEAMID.otherbundleidentifier"
		]
	}
}

iOS 11 and 12

Source: Setting up iOS Universal Links · GitHub

The apple-app-site-association will only be read from the .well-known directory, dropping support for the same file in the website root directory. If you still have the iOS 10 configuration in the website root, just move it to the /.well-known/ directory, it will still work on iOS 10, and will support iOS 11+.

In iOS 11+, the configuration file added support for various new services (applinks, webcredentials, appclips, maybe others). For universal links we are interested in the applinks service, which requires the apps and details keys.
I could not find information on what is the purpose of the apps key, why it needs to be an empty array, and if it’s actually required (most links I found said that it is required to be empty…). I can only suppose that it’s a legacy remnant from the iOS 10 configuration, which is not used in the iOS 11+ configuration, but must be present for legacy support reasons.
Anyway, the details key is what iOS 11+ will check for. It must be an array of per-app configurations. The difference from iOS 10 config is that you can now specify which paths you want to handle with your universal links (or match all with the "*" (any) wildcard, more on that later).

The configuration file requires the following format:

{
	"applinks": {
		"apps": [],
		"details": [
			{
				"appID": "TEAMID.bundleidentifier",
				"paths": [ "/buy/*", "/help/website/*",  "/help/*" ]
			},
			{
				"appID": "OTHTEAMID.otherbundleidentifier",
				"paths": [ "/blog", "/blog/post/*" ]
			},
			{
				"appID": "YAOTHTEAMID.yetanotherbundleidentifier",
				"paths": [ "*" ]
			}
		]
	}
}

iOS 13 and 14

Source: Supporting associated domains | Apple Developer Documentation

iOS 13+ improves on the previous iOS 11/12 configuration by splitting the paths into components, with a greater level of configuration. You can now specify paths with fragments (/path#fragment), whether to actually exclude the specified path with the exclude key, and also add a comment key, in case you want to document what the path is used for.
Also, the configuration now is not per-app, but it’s per-app-groups. This aims to make it easier to associate multiple apps with the same config (consider the case of the full Facebook app, and the Facebook lite app: both handle the same links, with the same configurations).

The configuration file requires the following format:
(copied from the above source link)

{
	"applinks": {
		"apps": [],
		"details": [
			{
				"appIDs": ["ABCDE12345.com.example.app", "ABCDE12345.com.example.app2"],
				"components": [
					{
						"#": "no_universal_links",
						"exclude": true,
						"comment": "Matches any URL whose fragment equals no_universal_links and instructs the system not to open it as a universal link"
					},
					{
						"/": "/buy/*",
						"comment": "Matches any URL whose path starts with /buy/"
					},
					{
						"/": "/help/website/*",
						"exclude": true,
						"comment": "Matches any URL whose path starts with /help/website/ and instructs the system not to open it as a universal link"
					},
					{
						"/": "/help/*",
						"?": { "articleNumber": "????" },
						"comment": "Matches any URL whose path starts with /help/ and which has a query item with name 'articleNumber' and a value of exactly 4 characters"
					}
				]
			}
		]
	}
}

A note on the * (any) wildcard

You may be familiar with the * (any) wildcard, and you may expect for

  • https://*.mywebsite.com to match all links that begin with anything, and have .mywebsite.com in the domain name
  • https://mywebsite.com/* to match any link that follows into a subpath of the domain name.

Then you would be surprised to discover that:

  • https://test.dev.mywebsite.com does not match your configured *.<domain>
  • https://mywebsite.com/product/details does not match your app paths <domain>/*

Why?
The reason is that the * (any) wildcard matches any string of characters, except:

  • level domain separators (the dot)
  • path separators (the forward slash)
  • (possibly others?)

In order to match multiple domain levels or subpaths you have to explictly configure it so:
https://*.*.mywebsite.com/*/*

Also, please consider the order of precedence: if you specify the path /blog/* before /blog/post, then the all links to /blog/post will match against the first path, not the second. That is because the /blog/* will be evaluated first, and will match, before /blog/post gets a chance to be evaluated. The solution is to place these catch-all paths after any specific sub-paths that you may want to handle differently.

Merging configurations

Even if you followed along the above, it may not be obvious to you how to merge these different configurations in order to support all iOS versions (to a certain degree) with a single configuration file. But the solution is actually simple, it’s just a trick merging of only the root keys, and adding their values to the existing ones in the arrays.
Also, for some reason, newer iOS version configs should be listed before older ones (iOS 13-14 > iOS 11-12 > iOS 10 and lower). It seems that, failing to do so, fails the checks during app installation for some developers. Although, I suspect this is not true and the real issue was somewhere else, it doesn’t hurt to follow this rule to avoid possible issues if it actually is true.

EDIT
The AASA validator will validate the configuration in whichever order you write it. The issue seems to only be an iOS-level of preference of the order of configurations, or things of sort.

The following example merges all of the 3 above configurations into a single JSON config.
Notice how configs in the details key are just copy-pasted configs from the iOS 13+ and 11-12 configs, while the root simply contains the activitycontinuation service config required for iOS 10.

{
	"applinks": {
		"apps": [],
		"details": [
			{
				"appIDs": [ "TEAMID.bundleidentifier", "ABCDE12345.com.example.app2" ],
				"components": [
					{
						"#": "no_universal_links",
						"exclude": true,
						"comment": "Matches any URL whose fragment equals no_universal_links and instructs the system not to open it as a universal link"
					},
					{
						"/": "/buy/*",
						"comment": "Matches any URL whose path starts with /buy/"
					},
					{
						"/": "/help/website/*",
						"exclude": true,
						"comment": "Matches any URL whose path starts with /help/website/ and instructs the system not to open it as a universal link"
					},
					{
						"/": "/help/*",
						"?": { "articleNumber": "????" },
						"comment": "Matches any URL whose path starts with /help/ and which has a query item with name 'articleNumber' and a value of exactly 4 characters"
					}
				]
			},
			{
				"appID": "TEAMID.bundleidentifier",
				"paths": [ "/buy/*", "/help/website/*",  "/help/*" ]
			},
			{
				"appID": "OTHTEAMID.otherbundleidentifier",
				"paths": [ "/blog", "/blog/post/*" ]
			},
			{
				"appID": "YAOTHTEAMID.yetanotherbundleidentifier",
				"paths": [ "*" ]
			}
		]
	},
	"activitycontinuation": {
		"apps": [
			"TEAMID.bundleidentifier",
			"OTHTEAMID.otherbundleidentifier"
		]
	}
}
3 Likes

@cristian-nxtl thank you so much for this write up! I’ll add it to the docs (I agree that the information for this is all spread out and very hard to find)

I went through this when testing universal links myself and said I’d write a better guide for it but just never got around to it :disappointed: but I’m so glad that you did!

1 Like

As I said, you’re welcome, I’m glad I could contribute a little bit :slight_smile:

This topic was automatically closed 20 days after the last reply. New replies are no longer allowed.