← All posts

6 min read

Deep Linking in Flutter with go_router: Universal Links and Android App Links

Set up deep linking in Flutter with go_router, iOS Universal Links and Android App Links: verification files, auth redirects, and testing with adb and simctl.

  • Flutter
  • Deep Linking
  • Navigation
Cover illustration for Deep Linking in Flutter with go_router: Universal Links and Android App Links

A user taps a link in an email: https://example.com/orders/42. One of three things happens. The app opens on order 42, which is great. The app opens on the home screen, which is confusing. Or the link opens a browser tab while the app sits installed and ignored, which is the worst of the three.

Deep linking is one of those features that’s mostly configuration. It involves two JSON files on a web server, an entitlement, an intent filter and a router, and if any one of them is wrong, the link fails silently. No error, no log, just a browser tab.

Here’s how I set it up with go_router, and how I check every layer when it doesn’t work.

There are two kinds of deep links:

  • Custom schemes (myapp://orders/42) are easy to set up, but any app can claim the same scheme, and they don’t fall back to a website when the app isn’t installed.
  • Verified HTTPS links (iOS Universal Links, Android App Links) use your real domain. The OS checks that you own both the domain and the app, opens the app if it’s installed, and opens your website if it isn’t.

For anything user-facing, like emails, SMS and shared links, I use verified HTTPS links. Custom schemes are still handy for OAuth redirects and internal tooling.

If you’re following an older tutorial that uses Firebase Dynamic Links, stop. Google deprecated the service and shut it down in August 2025, so those links no longer work. The replacement is the setup in this post: verified links on your own domain, plus your own website as the fallback.

Step 1: Define routes that match your URLs

Design your app routes to mirror your web URLs. Then the same link works on the web, on iOS and on Android:

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
      routes: [
        GoRoute(
          path: 'orders/:id',
          builder: (context, state) =>
              OrderScreen(orderId: state.pathParameters['id']!),
        ),
      ],
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => LoginScreen(
        from: state.uri.queryParameters['from'],
      ),
    ),
  ],
);

Because orders/:id is nested under /, opening a deep link builds a back stack with the home screen underneath. Pressing back goes somewhere sensible instead of closing the app.

Treat path parameters as untrusted input. The order screen should handle “this ID doesn’t exist” and “this order belongs to someone else” just as gracefully as a valid ID.

This is the case most apps get wrong. A logged-out user taps a link to an order. They should see the login screen, then land on the order after signing in, not on the home screen.

go_router’s redirect handles this. Remember where the user was going, and send them there after login:

GoRouter(
  refreshListenable: authNotifier, // a ChangeNotifier that fires on login/logout
  redirect: (context, state) {
    final loggedIn = authNotifier.isLoggedIn;
    final goingToLogin = state.matchedLocation == '/login';

    if (!loggedIn && !goingToLogin) {
      final from = Uri.encodeComponent(state.uri.toString());
      return '/login?from=$from';
    }
    if (loggedIn && goingToLogin) {
      return state.uri.queryParameters['from'] ?? '/';
    }
    return null; // no redirect
  },
  routes: [/* ... */],
);

refreshListenable makes the router re-run redirect when auth state changes, so a successful login sends the user on to their destination automatically. Before you redirect to from, check that it’s a relative path inside your app (it starts with /), so nobody can use your login screen as an open redirect. I go deeper into auth flows in production-ready authentication in Flutter.

Step 3: Prove you own the domain

Both platforms fetch a JSON file from your domain’s /.well-known/ directory to verify the link. The files must be served over HTTPS, with no redirects, and return valid JSON.

iOS: apple-app-site-association (no file extension):

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.example.app"],
        "components": [
          { "/": "/orders/*" },
          { "/": "/invite/*" }
        ]
      }
    ]
  }
}

The app ID is your Team ID followed by your bundle ID. Serve it with Content-Type: application/json. Apple fetches this file through its own CDN, so changes can take a while to show up on devices. Don’t panic if an edit doesn’t take effect instantly.

Android: assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": ["AB:CD:EF:..."]
    }
  }
]

The classic mistake is the fingerprint. If you use Play App Signing, Google re-signs your app with its key, so the fingerprint you need is the app signing key from the Play Console (under App integrity), not your local upload key. Add your debug key’s fingerprint too if you want links to work in local builds. The array accepts several fingerprints.

Step 4: Configure the apps

Android: add an intent filter with autoVerify to your main activity in 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="example.com" />
</intent-filter>

iOS: in Xcode, add the Associated Domains capability to the Runner target with an entry of applinks:example.com. Add one entry per domain, including www if you use it.

Flutter’s deep linking flag: Flutter has a built-in deep link handler that passes incoming links to your router. It’s controlled by FlutterDeepLinkingEnabled in iOS’s Info.plist and by a flutter_deeplinking_enabled meta-data entry in the Android manifest. In recent Flutter versions it’s on by default, so with go_router alone you usually don’t need to touch it. If you use a separate link-handling package such as app_links for custom processing, set the flag to false. Otherwise Flutter and the package both try to handle the same link. Check the Flutter deep linking docs for the default on your Flutter version.

Step 5: Test each layer

When a link opens the browser instead of the app, test from the bottom up.

Is the file reachable? Fetch it the way the OS will:

curl -i https://example.com/.well-known/assetlinks.json
curl -i https://example.com/.well-known/apple-app-site-association

Look for a 200, no redirects and JSON content. A surprising number of hosting setups redirect /.well-known/ or serve a single-page app’s index.html in its place.

Does Android consider the domain verified?

adb shell pm get-app-links com.example.app
adb shell pm verify-app-links --re-verify com.example.app

The first command shows the verification state per domain. You want verified.

Does the route work? Fire the link straight at the app:

# Android
adb shell am start -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d "https://example.com/orders/42"

# iOS Simulator
xcrun simctl openurl booted "https://example.com/orders/42"

Finally, test like a user: paste the link into Notes or Messages on a real device and tap it. On iOS, typing a URL straight into Safari’s address bar doesn’t trigger Universal Links. That’s deliberate, and a common source of “it doesn’t work” reports.

Test all the states, too: app not installed, installed and closed, open in the background, logged out. Links opened from push notifications go through the same router, as I describe in push notifications with FCM.

Takeaways

  • Use verified HTTPS links (Universal Links and App Links) for anything user-facing. Firebase Dynamic Links is gone.
  • Mirror your web URLs in go_router routes, and nest them so deep links get a sensible back stack.
  • Handle logged-out users with redirect and a validated from parameter, and re-run it with refreshListenable.
  • Host apple-app-site-association and assetlinks.json under /.well-known/ over HTTPS with no redirects, using the Play app signing fingerprint.
  • Debug from the bottom up: curl the files, check pm get-app-links, then fire links with adb and simctl.

Deep links are a small amount of code and a lot of configuration. Getting each layer right once saves a lot of silent failures later.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).