Flutter Deep Linking Without an SDK: Universal Links, App Links and Install Attribution
Flutter deep linking needs OS wiring either way - AASA, assetlinks.json, intent filters. What you can skip is the vendor SDK: app_links plus two HTTPS calls.
Quick answer: Deep linking in Flutter works through the same OS machinery as any native app - an Associated Domains entitlement and a hosted apple-app-site-association file on iOS, an autoVerify intent filter and assetlinks.json on Android. That wiring is unavoidable, whatever tooling you choose. What you can skip is embedding an attribution vendor's SDK: the community app_links package delivers incoming links to your Dart code, and install attribution plus deferred deep linking work over two HTTPS calls to LinkTrace - nothing vendor-specific in your binary, no wrapper around your navigation. This guide is the full setup: the OS layers that stay, the Dart that receives links, where attribution fits, and how to test each layer. Flutter and package details checked 5 November 2026.
$ cat table-of-contents
- The four layers of Flutter deep linking
- Custom schemes vs Universal Links and App Links
- iOS setup: entitlement and the AASA file
- Android setup: intent filter and assetlinks.json
- Receiving links in Dart with app_links
- One flag to avoid double navigation
- Install attribution and deferred deep linking without an SDK
- Testing each layer
- Frequently asked questions
- If you remember three things
- Sources
The four layers of Flutter deep linking
"Flutter deep linking" sounds like one feature, but four independent layers have to agree before a tap lands a user on the right screen:
- OS verification. iOS and Android verify that a domain belongs to your app by fetching a JSON file from it -
apple-app-site-association(AASA) on iOS,assetlinks.jsonon Android. This happens entirely in the native layer, before a line of Dart runs. - Native app configuration. The Associated Domains entitlement in the Xcode project and the intent filter in
AndroidManifest.xmldeclare which hosts your app claims. - Link delivery into Flutter. Something has to hand the incoming URL to your Dart code: Flutter's own deep link handler, the
app_linkspackage, or a vendor SDK. - Routing. Your navigation stack - the
Routerwidget, a package likego_router, or a listener that calls your navigator - turns the URL into a screen.
Layers 1 and 2 are identical for every app on earth, SDK or no SDK. Layers 3 and 4 are where you have real choices - and where this guide's "without an SDK" claim lives. Nothing here removes the OS wiring.
Custom schemes vs Universal Links and App Links
Three kinds of links can open your app, and they fail differently:
| Link type | Looks like | Verified by the OS | Works when the app is not installed |
|---|---|---|---|
| Custom URL scheme | myapp://product/42 |
No - any app can claim the same scheme | No - the tap dead-ends on an error |
| Universal Link (iOS) | https://links.example.com/abc |
Yes - via the AASA file | Yes - it is a normal URL, so the web page loads |
| App Link (Android) | https://links.example.com/abc |
Yes - via assetlinks.json | Yes - same: the web page loads |
Ship Universal Links and App Links. A custom scheme is fine for local development, but it is an unverified claim - another app can register the same scheme - and it gives an installer-without-the-app a dead end instead of a path to the store. Universal Links and App Links are ordinary HTTPS URLs the OS has cryptographically tied to your app: no other app can intercept them, and when the app is missing they still open your web page, from which you route to the store. That fallback is also what makes deferred deep linking possible; more on that below.
iOS setup: entitlement and the AASA file
The Associated Domains entitlement
In Xcode, open ios/Runner.xcworkspace, select the Runner target, open Signing & Capabilities, add Associated Domains, and add one entry per link host: applinks:links.example.com. That writes to ios/Runner/Runner.entitlements:
Runner.entitlements
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:links.example.com</string>
</array>
Enable the same capability on your App ID in the Apple Developer portal, and regenerate the provisioning profile if needed - a mismatched entitlement fails at signing time. During development you can append ?mode=developer to the entry and enable Associated Domains Development in the device's Settings, which makes iOS fetch the file from your server directly instead of Apple's cache; remove the suffix before shipping.
The apple-app-site-association file
Apple verifies the entitlement against a file your domain serves at https://links.example.com/.well-known/apple-app-site-association:
apple-app-site-association
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.myapp"],
"components": [
{ "/": "/*", "comment": "Every link on this host" }
]
}
]
}
}
ABCDE12345 is your Apple Team ID from the developer portal's Membership page. The rules that bite, roughly in the order people hit them: serve it over HTTPS with a valid certificate and no redirect at any hop (a 301 to www. is a failure); send Content-Type: application/json with no .json extension on the path; put no authentication or bot challenge in front of it; and keep it under Apple's size cap. The file is fetched by Apple's CDN at install time and refreshed on Apple's schedule, not yours - so a correct file on your server can sit next to a stale copy on the device. That cache is the single most common reason a fix "does not work"; developer mode and a delete-and-reinstall both bypass it. Apple's own reference is Supporting associated domains.
Android setup: intent filter and assetlinks.json
The intent filter
In android/app/src/main/AndroidManifest.xml, add an intent filter to your main activity with android:autoVerify="true" - the flag that asks Android to verify the domain against your app:
AndroidManifest.xml
<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="https" android:host="links.example.com" />
</intent-filter>
The assetlinks.json file
Android verifies the host against https://links.example.com/.well-known/assetlinks.json, which names your package and your signing certificate's SHA-256 fingerprint:
assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": ["AA:BB:CC:...:FF"]
}
}]
If you use Play App Signing, the fingerprint is the one Play Console shows under App integrity - not your upload key's. A release build signed by a different key than the fingerprint you published will verify on your debug device and fail for every user. Google's reference is Android App Links.
Receiving links in Dart with app_links
With the OS layers done, something must hand the URL to Dart. The maintained community option is app_links (version 7.2.2 as of 5 November 2026, from a verified pub.dev publisher, Apache-2.0, several million downloads). It handles Android App Links, iOS Universal Links and custom schemes, and its job ends at delivery: it does not own your navigation, your attribution, or your link hosting.
pubspec / main.dart
flutter pub add app_links
final appLinks = AppLinks(); // singleton - create it early
// Subscribe early enough to catch the cold-start link:
final sub = appLinks.uriLinkStream.listen((uri) {
// Turn the URI into navigation, e.g. your Router or a named route.
});
The one discipline that matters is timing: instantiate AppLinks and subscribe as early as possible, because a link that launched the app cold can arrive before your first frame. The package's own guidance is to set the listener up early to catch that initial link - build your routing so a link arriving before the navigator is ready is queued, not dropped. Routing itself stays in whatever you already use: the Router widget (Flutter's recommended approach; the docs note named routes are no longer recommended for most apps) or go_router, which parses deep-link paths into its route table directly.
One flag to avoid double navigation
Flutter ships its own deep link handler, which turns incoming URLs into routes for your router. If a plugin such as app_links also delivers the same URL, you can get duplicate navigation - the link consumed twice. Flutter's documentation covers the opt-out: set FlutterDeepLinkingEnabled to false in Info.plist and flutter_deeplinking_enabled to false in AndroidManifest.xml, so one source - your app_links listener - drives navigation. If you instead let Flutter's handler own links entirely, leave the flags alone and skip app_links.
Install attribution and deferred deep linking without an SDK
Everything above gets a link to open a screen. It does not answer "which link produced this install", and it does nothing for the user who tapped, had no app, and installed first. Those two jobs - install attribution and deferred deep linking - are where vendors usually ask you to embed an SDK. LinkTrace (this site) does both over two HTTPS calls, so your Flutter binary carries no vendor code at all:
1. Create the tracked link (from your backend)
curl -s -X POST https://api.linktrace.in/api/v1/referral-links \
-H "x-api-key: $LT_API_KEY" \
-d '{"referrerIdentifier":"campaign_x","source":"newsletter","campaign":"nov_launch"}'
2. Resolve the install on first launch (from the app)
curl -s -X POST https://api.linktrace.in/api/v1/attributions \
-H "x-api-key: $LT_API_KEY" \
-d '{ "device": { ... } }'
{ "data": { "attributed": true, "referrerIdentifier": "campaign_x",
"campaign": "nov_launch" } }
The second call also returns the deep-link payload attached to the link, which is how deferred deep linking works without a SDK: the new installer opens the app, your first-launch code calls the API, and you route to the screen the original link promised. The full mechanics are in our deferred deep linking implementation guide.
Two honest boundaries. First, the OS wiring from the previous sections is still yours: app_links handles URLs that open the installed app, while LinkTrace attributes the install and carries the destination through the store gap - they are complementary, not substitutes. Second, on the Scale plan LinkTrace hosts the domain-side verification files (AASA and assetlinks.json) on your branded subdomain, but the app-side entitlement, manifest declarations and signing fingerprints remain yours either way (checked against our pricing page, 5 November 2026).
Testing each layer
Test the layers separately, or you will debug the wrong one:
Layer 1-2: does the OS verify the link? (simulator/emulator)
# iOS simulator
xcrun simctl openurl booted "https://links.example.com/abc"
# Android emulator
adb shell am start -a android.intent.action.VIEW \
-d "https://links.example.com/abc"
If the browser opens instead of the app, the failure is in verification (AASA/assetlinks reachability, entitlement, fingerprint), not in Dart. If the app opens but lands on the home screen, the failure is in delivery or routing (layers 3-4). And if a fix "does nothing", suspect the AASA CDN cache before anything else - delete the app and reinstall, or use developer mode. For the attribution layer, tap a tracked link on a device without the app, install a debug build, and confirm the first-launch call returns the campaign you attached.
Frequently asked questions
Do I need an SDK for deep linking in Flutter?
No. The OS verification layers (AASA, assetlinks.json, entitlement, intent filter) are configuration, not code from a vendor. To receive links in Dart, the community app_links package or Flutter's own handler is enough, and install attribution plus deferred deep linking work over plain HTTPS calls to LinkTrace. Vendor SDKs bundle these jobs together; none of them is required for the links to work.
Do I still need Associated Domains and assetlinks.json if I use LinkTrace?
Yes. Universal Links and App Links are verified by iOS and Android against files on your domain and declarations in your app - no third party can do that for you. On LinkTrace's Scale plan we host the domain-side files on your branded subdomain (checked 5 November 2026), but the entitlement, the manifest and the signing setup remain in your project.
What happened to Firebase Dynamic Links for Flutter?
Firebase Dynamic Links - the historical default for Flutter deep linking - shut down on 25 August 2025, and all FDL links stopped working. Migration means exactly the setup in this guide: your own verified domain plus Universal Links and App Links. Our alternatives guide covers the migration paths.
Should I use app_links or go_router for deep links?
They answer different questions. app_links delivers the incoming URL to your code, including on cold start. go_router decides which screen a path maps to, and can consume deep links through Flutter's built-in handler. Many apps use either app_links feeding their own navigation, or Flutter's handler plus go_router - but not two delivery paths at once (see the double-navigation flag above).
Why does my Universal Link open the browser instead of the app?
Almost always layer 1: the AASA file is unreachable, redirected, served with the wrong content type, or stale in Apple's CDN cache. Check the file from a plain HTTPS client, confirm the entitlement host matches exactly, then delete and reinstall the app. Only after that should you look at Flutter-side handling.
Does deferred deep linking work without an SDK?
Yes. The store visit and install break the link's context; the fix is a server that remembers which link the tap came from. LinkTrace keeps that mapping; on first launch your app calls the attribution API over HTTPS and gets back the destination and campaign, then routes accordingly. No SDK, just one call at first launch.
If you remember three things
- The OS wiring stays, SDK or not. Associated Domains, AASA, the autoVerify intent filter and assetlinks.json are how iOS and Android verify your links. Budget the time for them; they are most of the work.
- Delivery and routing are separate choices.
app_links(or Flutter's handler) gets the URL into Dart; your router turns it into a screen. Pick one delivery path, and setFlutterDeepLinkingEnabled/flutter_deeplinking_enabledto false if a plugin owns it. - Attribution does not require an SDK in your binary. Link creation and install resolution are two HTTPS calls; your Flutter app stays free of vendor code, and deferred deep linking rides on the first-launch call.
Sources
Flutter, package and platform details were checked against the pages below on 5 November 2026.
- Flutter: Deep linking (Router-based deep linking, named-routes guidance, the FlutterDeepLinkingEnabled / flutter_deeplinking_enabled opt-out)
- pub.dev: app_links 7.2.2 (supported link types, early-instantiation guidance, usage)
- pub.dev: go_router (declarative routing with deep-link support)
- Apple: Supporting associated domains (entitlement and AASA hosting rules)
- Google: Android App Links (autoVerify intent filters and assetlinks.json verification)
- WarpLink: Flutter Deep Linking guide (an SDK-centred take on the same setup; useful cross-check on the cold-start race and AASA caching)
- LinkTrace: linktrace.in (two-call link creation and first-launch attribution) and Pricing (plan allowances, branded subdomains on Scale)
LinkTrace is a deep linking and install attribution service for startups and app developers. Two HTTPS calls, flat pricing, no SDK. Create a free account or see the plans.
Related guides: Universal Links vs App Links, Deep Link Not Working? How to Debug Universal Links and App Links, Deferred Deep Links Implementation Guide, Firebase Dynamic Links Alternatives, and Track App Installs Without Firebase or AppsFlyer