Edok Studio Edok Studio
Guide

Deep links in Flutter, step by step

Updated September 2, 2026 13 min read

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:

  1. Declare that your app owns the link domain.
  2. Receive the tapped URL inside Dart.
  3. Ask where that link points.
  4. Route to the screen.
Every sample below uses 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

ValueExampleWhere it goes
Link domainlinks.example.comentitlement + manifest
iOS Team IDA1B2C3D4E5dashboard, so we can serve your AASA
iOS Bundle IDcom.example.appdashboard
Android packagecom.example.appdashboard
Android SHA-256AB:CD:…dashboard

We host the two verification files for you. There is nothing to upload and no web server to run.

The signing fingerprint that matters is the one from Play Console → Test and release → App signing, not the one from your own keystore. Google re-signs every uploaded AAB, so an app that lists only the local keystore fingerprint works in testing and silently stops working the day it ships. Add both, plus your debug fingerprint.

1. Add the plugin

pubspec.yamlyaml
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 step

app_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:

android/app/src/main/AndroidManifest.xmlxml
<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:

iOS

Create ios/Runner/Runner.entitlements:

ios/Runner/Runner.entitlementsxml
<?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+ CapabilityAssociated 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”.

You must also enable Associated Domains on the App ID at developer.apple.com → Certificates, Identifiers & Profiles, then regenerate the provisioning profile. Without it the entitlement is stripped at signing and links open Safari with no error anywhere.

Finally, switch Flutter’s own router off on iOS too, in ios/Runner/Info.plist:

ios/Runner/Info.plistxml
<key>FlutterDeepLinkingEnabled</key>
<false/>

Verify the entitlement actually made it into the binary:

Check the signed appbash
codesign -d --entitlements :- build/ios/iphoneos/Runner.app | grep -A3 associated

3. Receive the URL

main.dartdart
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:

link_router.dartdart
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:

resolve_link.dartdart
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;
  }
}
Keep the timeout short — four seconds is plenty. This request sits between a tap and a screen. A slow answer is worse than no answer: when it expires, show your home screen and move on.

What you get back

Two possible answersjson
{ "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

main.dartdart
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 card

API 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

Requesthttp
GET https://links.example.com/l/<slug>
Accept: application/json

The 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.

Response — 200 in both casesjson
{ "matched": true,  "linkId": 12452, "slug": "spring-sale", "deepLinkPath": "/product/123" }
{ "matched": false, "linkId": null,  "slug": null,          "deepLinkPath": null }
FieldTypeMeaning
matchedboolfalse = unknown, inactive, or deleted slug
linkIdint | nullOur id for the link
slugstring | nullThe slug that matched
deepLinkPathstring | nullThe 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)

Requesthttp
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.

http
GET https://links.example.com/.well-known/apple-app-site-association
GET https://links.example.com/.well-known/assetlinks.json

They 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.

pubspec.yamlyaml
dependencies:
  play_install_referrer: ^0.5.0
claim_install.dartdart
import '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.

There is no iOS equivalent, from anyone. Apple provides nothing that carries a payload through an App Store install. The only alternative is guessing from IP address and device details, which fails on any shared network. What works reliably and for free: on your post-install screen, tell the user to tap the link again — by then it is an ordinary Universal Link and opens the right screen.

Creating a link

In Edok Studio, open your project → Links+ New link.

FieldExampleWhat it does
NameSummer saleA label for your own reference in the dashboard. Never shown to users, never sent to your app.
Addresssummer-saleThe slug. Produces https://links.example.com/l/summer-sale — this is the URL you share.
Opens in the app/product/123The path your app routes to. Leave blank to open the home screen.
Desktop fallbackhttps://example.com/product/123Where laptops go. Phones without the app are sent to the App Store or Play Store. Defaults to your site.
Link is activetickedUntick 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:

The whole round trip
Dashboard:  Opens in the app  →  /product/123
Resolve:    {"matched": true, "deepLinkPath": "/product/123"}
Your code:  router.offer("/product/123")  →  your go_router / Navigator

So 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

Click counts read lower than reality, and that is expected. When somebody already has your app, the operating system opens it directly. Those taps never reach our servers — or anyone else’s — so no product in this category can count them.

Testing

Android

adbbash
# 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:

Force the per-user selection onbash
adb shell pm set-app-links-user-selection --user cur \
  --package com.example.app true links.example.com

iOS

You cannot test deep links with a Flutter iOS debug build. A debug build is killed the instant the OS launches it, and every Universal Link tap is an OS launch — so the app appears to open and immediately close. Build release or profile instead. Android has no such restriction, which makes this easy to misread as a broken link.
Install a testable buildbash
flutter build ios --release
flutter install -d <device-id>
A URL typed into Safari’s address bar never triggers a Universal Link. iOS treats it as direct navigation and loads the web page. Put the link in Notes, tap outside so it turns blue, then tap it.

On the Simulator you can script it:

Simulatorbash
xcrun simctl openurl booted "https://links.example.com/l/your-slug"

Troubleshooting

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.

Frequently asked questions

Why do deep links seem to open and instantly close my Flutter iOS app?

You are testing a debug build. A Flutter iOS debug build is killed the moment the operating system launches it, and every Universal Link tap is an OS launch — so the app appears to open and immediately close. Build release or profile: flutter build ios --release, then flutter install -d <device-id>. Android has no such restriction, which makes this easy to misread as a broken link.

Should I use app_links or Flutter’s built-in deep linking?

Pick one. If you use app_links, switch Flutter’s own router off — meta-data flutter_deeplinking_enabled = false in AndroidManifest.xml and FlutterDeepLinkingEnabled = false in Info.plist — otherwise both try to consume the same intent. app_links hands you the raw URL, which is what you need in order to resolve a slug before routing.

Why is my Flutter app missing the link that cold-started it?

app_links replays the launch URL to its FIRST subscriber only, so subscribing late loses exactly the link that opened your app. Subscribe to uriLinkStream as early as possible, and do not also call getInitialLink() — the stream already replays it, and calling both routes the same link twice.

Do I need separate configuration for iOS and Android in Flutter?

Yes. The Dart half is shared, but each platform verifies domain ownership its own way: Android needs an autoVerify intent filter in AndroidManifest.xml plus your SHA-256 fingerprints, and iOS needs an Associated Domains entitlement, the capability enabled on the App ID at developer.apple.com, and a regenerated provisioning profile. Neither can be done from Dart.

A change I made has no effect on deep links. Why?

Flutter’s incremental build can ship stale Dart. Run flutter clean && flutter pub get and rebuild before you believe any deep-link symptom — this is worth doing first, because chasing a link bug that a rebuild fixes wastes more time than the rebuild.

Keep reading