This guide takes a Flutter app from nothing to a link that opens the right screen, on both platforms. One Dart implementation, two platform configurations, and no SDK — deep links are an operating-system feature and app_links is only the bridge that hands you the URL.
Four steps, and only the first one needs an app release:
- Declare that your app owns the link domain.
- Receive the tapped URL inside Dart.
- Ask where that link points.
- Route to the screen.
links.example.com as the link domain. Replace it with the domain shown on your project’s Links → App setup tab — that tab also holds the identifiers and fingerprints, and shows these snippets with your real values already filled in.Before you start
| Value | Example | Where it goes |
|---|---|---|
| Link domain | links.example.com | entitlement + manifest |
| iOS Team ID | A1B2C3D4E5 | dashboard, so we can serve your AASA |
| iOS Bundle ID | com.example.app | dashboard |
| Android package | com.example.app | dashboard |
| Android SHA-256 | AB:CD:… | dashboard |
We host the two verification files for you. There is nothing to upload and no web server to run.
1. Add the plugin
dependencies:
app_links: ^6.4.1 # use ^7.0.0 on Flutter 3.44+
http: ^1.6.0
shared_preferences: ^2.5.5 # only for the optional install-claim stepapp_links hands you the raw URL. Everything after that is your own code — which is the point: there is no third party between the tap and your router.
2. Platform configuration
Android
In android/app/src/main/AndroidManifest.xml, inside your launch activity:
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<!-- Let app_links handle links, not Flutter's built-in router.
Without this both try to consume the same intent. -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
<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"
android:pathPrefix="/l/" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>Three things here are easy to get wrong:
pathPrefixends with a slash.pathPrefix="/l"is a literal prefix match, so it also claims/login,/legaland/latest. Write/l/.launchMode="singleTop". Without it, a link tapped while your app is running starts a second copy of your activity instead of delivering to the one already open.- List only hosts that resolve. Android verifies every host in an
autoVerifyfilter as one group: if any fails, the whole filter fails and your working domain stops opening too. Never add awww.host that has no DNS record.
iOS
Create ios/Runner/Runner.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:links.example.com</string>
</array>
</dict>
</plist>Then in Xcode: select the Runner target → Signing & Capabilities → + Capability → Associated Domains, and confirm the file is set as CODE_SIGN_ENTITLEMENTS for all three configurations — Debug, Release and Profile. Missing it on one is a common cause of “works for me, not in TestFlight”.
Finally, switch Flutter’s own router off on iOS too, in ios/Runner/Info.plist:
<key>FlutterDeepLinkingEnabled</key>
<false/>Verify the entitlement actually made it into the binary:
codesign -d --entitlements :- build/ios/iphoneos/Runner.app | grep -A3 associated3. Receive the URL
import 'package:app_links/app_links.dart';
final appLinks = AppLinks();
// Subscribe as early as possible — app_links replays the URL that cold-started
// the app to its FIRST subscriber only. Subscribe late and you lose exactly the
// link that opened your app.
final sub = appLinks.uriLinkStream.listen((Uri uri) {
handleLink(uri);
});Do not also call getInitialLink(). The stream already replays it; calling both routes the same link twice.
Park the link until a screen can take it
A link routinely arrives before there is anywhere to send it — during a cold start your app may still be signing in, and a first-run user has onboarding to finish. Hold the newest link rather than dropping it:
class LinkRouter extends ChangeNotifier {
String? _pendingPath;
String? get pending => _pendingPath;
void offer(String path) {
_pendingPath = path; // newest wins; do not queue a backlog
notifyListeners();
}
String? consume() {
final path = _pendingPath;
_pendingPath = null; // exactly once
return path;
}
}Your first real screen calls consume() on mount and on every notification.
4. Resolve the link
A Smart Link URL carries a slug, not a destination — /l/spring-sale, not /product/123. That indirection is the point: you can re-point a link that is already out in the world without shipping an app update. It also means that when your app is installed, the operating system hands the URL straight to your app and never contacts us, so your app has to ask where the slug points.
Request the link URL itself with Accept: application/json:
import 'dart:convert';
import 'package:http/http.dart' as http;
final _client = http.Client();
/// Returns the in-app path for [url], or null if it cannot be resolved.
/// Never throws — a link tap needs an answer or a null, not an exception.
Future<String?> resolveLink(Uri url, {Duration timeout = const Duration(seconds: 4)}) async {
// The timeout must wrap BOTH sending and reading the body. Putting it only
// on the second half leaves a hung connection able to block indefinitely.
Future<http.Response> send() async {
// Do not follow redirects: a 3xx means we were answered as a browser, and
// that response carries no destination for an app.
final request = http.Request('GET', url)
..followRedirects = false
..headers['Accept'] = 'application/json';
return http.Response.fromStream(await _client.send(request));
}
try {
final response = await send().timeout(timeout);
if (response.statusCode != 200) return null;
if (!(response.headers['content-type'] ?? '').contains('json')) return null;
final body = jsonDecode(response.body);
if (body is! Map<String, dynamic>) return null;
if (body['matched'] != true) return null; // unknown, inactive, or deleted
final path = body['deepLinkPath'];
return (path is String && path.isNotEmpty) ? path : null;
} catch (_) {
// No connectivity, DNS failure, TLS error, a captive portal serving HTML —
// all of it must land the user in the app, never on a stuck splash screen.
return null;
}
}What you get back
{ "matched": true, "linkId": 12452, "slug": "spring-sale", "deepLinkPath": "/product/123" }
{ "matched": false, "linkId": null, "slug": null, "deepLinkPath": null }matched: false is the normal answer for a slug that is unknown, switched off, or deleted. A link outlives whoever shared it and people do tap old ones, so treat it as “could not open this”, never as an error.
5. Put it together
Future<void> handleLink(Uri uri) async {
final path = await resolveLink(uri);
if (path == null) {
// Show a brief message, then carry on into the app.
return;
}
router.offer(path); // e.g. "/product/123"
}Route path with whatever you already use — go_router, Navigator, your own parser. Validate it before use: treat it as untrusted input and match it against routes you know, rather than passing segments straight into a database lookup.
Host your link domain and both verification files in Edok Studio
Design once with device frames and export every required App Store & Google Play size in one ZIP — watermark-free on the free plan.
Start free — no cardAPI reference
Everything your app talks to. No API key and no authentication — these endpoints are public, because an app installed on someone’s phone has no credentials it could safely present.
Resolve a link
GET https://links.example.com/l/<slug>
Accept: application/jsonThe same URL a browser opens. The Accept header is the whole difference: without it you get the human-facing interstitial page and a redirect to the store.
{ "matched": true, "linkId": 12452, "slug": "spring-sale", "deepLinkPath": "/product/123" }
{ "matched": false, "linkId": null, "slug": null, "deepLinkPath": null }| Field | Type | Meaning |
|---|---|---|
matched | bool | false = unknown, inactive, or deleted slug |
linkId | int | null | Our id for the link |
slug | string | null | The slug that matched |
deepLinkPath | string | null | The path to route to, e.g. /product/123 |
Do not follow redirects on this request. A 3xx means you were answered as a browser — that response has no destination in it, and following it costs a second round trip to reach the same nothing.
Claim an install (Android only)
POST https://admin.edokstudio.com/api/public/links/claim-install
Content-Type: application/json
{ "referrer": "<the full Play install referrer string>" }Same response shape as resolve, so one routing function serves both.
Verification files
We serve these for you; you never upload anything.
GET https://links.example.com/.well-known/apple-app-site-association
GET https://links.example.com/.well-known/assetlinks.jsonThey return application/json with no file extension and no redirect — exactly what Apple and Google require. Open them in a browser to see what they see.
Optional: land new installs on the right screen (Android)
Without this, somebody who taps a link, installs from the Play Store and opens your app arrives at your home screen. The tap is lost across the store visit.
dependencies:
play_install_referrer: ^0.5.0import 'package:play_install_referrer/play_install_referrer.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Call once, on the first run after install.
Future<String?> claimInstall() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('install_claimed') ?? false) return null;
String? referrer;
try {
final details = await PlayInstallReferrer.installReferrer;
referrer = details.installReferrer; // e.g. "edok_link=AbC123..."
} catch (_) {
// iOS, or a device with no Play Services. Nothing to claim.
await prefs.setBool('install_claimed', true);
return null;
}
await prefs.setBool('install_claimed', true);
if (referrer == null || referrer.isEmpty) return null;
try {
final response = await http
.post(
Uri.parse('https://admin.edokstudio.com/api/public/links/claim-install'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'referrer': referrer}),
)
.timeout(const Duration(seconds: 4));
if (response.statusCode != 200) return null;
final body = jsonDecode(response.body);
if (body is! Map<String, dynamic> || body['matched'] != true) return null;
final path = body['deepLinkPath'];
return (path is String && path.isNotEmpty) ? path : null;
} catch (_) {
return null;
}
}Call it once and remember that you have. Google keeps the referrer available for 90 days, but repeat calls buy you nothing. This needs a Play Store URL set in your app-link config — without one we do not attach a referrer to the store link, and every claim returns matched: false.
Creating a link
In Edok Studio, open your project → Links → + New link.
| Field | Example | What it does |
|---|---|---|
| Name | Summer sale | A label for your own reference in the dashboard. Never shown to users, never sent to your app. |
| Address | summer-sale | The slug. Produces https://links.example.com/l/summer-sale — this is the URL you share. |
| Opens in the app | /product/123 | The path your app routes to. Leave blank to open the home screen. |
| Desktop fallback | https://example.com/product/123 | Where laptops go. Phones without the app are sent to the App Store or Play Store. Defaults to your site. |
| Link is active | ticked | Untick to switch a link off without deleting it. |
“Opens in the app” is the exact string your app receives. Whatever you type there comes back as deepLinkPath and lands in your routing function unchanged:
Dashboard: Opens in the app → /product/123
Resolve: {"matched": true, "deepLinkPath": "/product/123"}
Your code: router.offer("/product/123") → your go_router / NavigatorSo it has to be a path your router already understands. There is no translation layer — pick the same paths your app uses internally.
Things worth knowing
- Changing a link needs no app release. Point
summer-saleat a different screen whenever you like; links already shared follow. That is the whole reason the URL carries a slug instead of a path. - Adding a link needs no configuration change either. Your verification files claim
/l/*, so every future slug is already covered — no waiting on Apple’s CDN, no Android re-verification. - Set the desktop fallback per link. Left blank it goes to your site root, which drops all context for anyone who clicks on a laptop.
- Share the URL without a trailing slash.
https://links.example.com/l/summer-sale, not.../summer-sale/.
Testing
Android
# Is the domain verified on this device?
adb shell pm get-app-links com.example.app
# Fire a link
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://links.example.com/l/your-slug"You want Domain verification state: links.example.com: verified. If it says verified but links still open a browser, check the per-user selection further down that same output — on emulators and some dev images it can be Disabled even after successful verification:
adb shell pm set-app-links-user-selection --user cur \
--package com.example.app true links.example.comiOS
flutter build ios --release
flutter install -d <device-id>On the Simulator you can script it:
xcrun simctl openurl booted "https://links.example.com/l/your-slug"Troubleshooting
- A change you just made has no effect. Flutter’s incremental build can ship stale Dart. Run
flutter clean && flutter pub getand rebuild before you believe any deep-link symptom. - Android opens a chooser instead of your app. Verification has not passed. Re-check
adb shell pm get-app-links, and confirm the fingerprint in your dashboard matches the build you installed — a debug build needs the debug fingerprint listed. - iOS opens Safari. Usually one of: Associated Domains not enabled on the App ID; the entitlement missing from that build configuration; or the association not fetched yet. iOS fetches it in the background at install time — force-quit, wait a minute, try again.
- The link opens the app but lands on the home screen. Your resolve call is returning null. Log the status code and content type; a 3xx means the request did not carry
Accept: application/json. - The same link routes twice. You subscribed to
uriLinkStreamand also calledgetInitialLink(), or you left Flutter’s built-in deep linking enabled alongsideapp_links.
Building for more than one toolchain? See the native iOS guide and the native Android guide, or the platform-level explanation of why none of this needs an SDK.