+1 (415) 480-3939

Deep Links That Actually Open: go_router, App Links, and Universal Links

Deep linking is the feature that looks trivial in a demo and eats a sprint in production. go_router routes a URL in five minutes on a simulator; then marketing sends a campaign link, half the Android users land on the home screen, iOS opens Safari instead of the app, and a link tapped by a logged-out user drops them at the login screen and forgets where they were going.

This tutorial covers the whole path a link travels: platform verification files, native manifest configuration, a go_router setup with an auth redirect that preserves intent, and the commands we use to test links on real devices. Code is go_router 14.x on Flutter 3.4x.

The three kinds of links (they are not the same)

  • Custom schememyapp://work-orders/42. Works instantly, no server setup, but any app can register the same scheme and it does nothing when the app isn't installed. Fine for OAuth callbacks, wrong for marketing.
  • Android App Linkshttps://example.com/work-orders/42 verified against a assetlinks.json file on your domain. Opens the app directly, no disambiguation dialog.
  • iOS Universal Links — the same HTTPS URL verified against apple-app-site-association. Opens the app if installed, otherwise the web page.

Ship HTTPS links. Keep a custom scheme only for auth callbacks. Whatever you choose, the same URL must render a real page on the web, because links get opened on desktops.

Server side: the two verification files

Both files must be served over HTTPS, with no redirects, and Content-Type: application/json.

https://example.com/.well-known/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 fingerprint must be the signing key that Google Play uses, which is usually the Play App Signing key, not your upload key. Copy it from Play Console → Setup → App integrity. If you also distribute internal builds signed with a different key, list both fingerprints.

https://example.com/.well-known/apple-app-site-association (no .json extension):

{
  "applinks": {
    "details": [{
      "appIDs": ["TEAMID.com.example.app"],
      "components": [
        { "/": "/work-orders/*", "comment": "work order detail" },
        { "/": "/invite/*" },
        { "/": "/admin/*", "exclude": true }
      ]
    }]
  }
}

Verify both with curl -I before touching the app. A CDN that adds text/html or a 301 from example.com to www.example.com is the single most common cause of "it works on my machine".

Native configuration

Android — in android/app/src/main/AndroidManifest.xml, inside the main activity, add a second intent filter with android:autoVerify="true":

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

Keep the launcher intent filter separate; merging them breaks the app icon. Also add <meta-data android:name="flutter_deeplinking_enabled" android:value="true" /> so Flutter's built-in deep link handling forwards the URL to your router.

iOS — add the Associated Domains capability in Xcode with applinks:example.com, and set FlutterDeepLinkingEnabled to true in Info.plist.

Router: routes that mirror your URLs

final routerProvider = GoRouter(
  initialLocation: '/',
  navigatorKey: rootNavigatorKey,
  refreshListenable: authState,        // re-runs redirect on login/logout
  routes: [
    GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
    GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
    GoRoute(
      path: '/work-orders/:id',
      builder: (context, state) =>
          WorkOrderScreen(id: state.pathParameters['id']!),
      routes: [
        GoRoute(
          path: 'photos',                       // /work-orders/42/photos
          builder: (context, state) =>
              PhotosScreen(id: state.pathParameters['id']!),
        ),
      ],
    ),
  ],
  errorBuilder: (context, state) => NotFoundScreen(uri: state.uri),
);

Nesting matters: because photos is a child route, context.pop() from a deep-linked photo screen lands on the work order, not on a dead end. A flat route list gives cold-started users a back button that exits the app.

The auth redirect that preserves intent

This is where most implementations lose the link. The rule: when you bounce an unauthenticated user to login, carry the destination, and when they authenticate, send them there.

redirect: (context, state) {
  final loggedIn = authState.isLoggedIn;
  final goingToLogin = state.matchedLocation == '/login';

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

Two details that bite: return null (not the current location) when no redirect is needed, or you can loop; and while auth state is still loading, redirect everything to a /splash route instead of /login, otherwise a cold start with a valid token flashes the login screen and discards the link.

Cold start vs. warm start

A warm start delivers the URL through the platform's link stream while your app is alive. A cold start delivers it as the initial route before your first frame. go_router handles both when FlutterDeepLinkingEnabled / flutter_deeplinking_enabled are set — but only if you do not hardcode initialLocation logic that overrides it, and only if any splash/bootstrap screen re-issues the pending location after initialization instead of calling go('/').

If you need to intercept links yourself (for example to strip campaign parameters before routing), use app_links:

final appLinks = AppLinks();
final initial = await appLinks.getInitialLink();   // cold start
appLinks.uriLinkStream.listen(_handle);            // warm start

Testing links without publishing a campaign

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

# Android: check verification status
adb shell pm get-app-links com.example.app

# iOS simulator
xcrun simctl openurl booted "https://example.com/work-orders/42"

pm get-app-links printing verified for your domain is the only proof that App Links are working; none or legacy_failure means the assetlinks.json fetch failed, usually TLS, redirect, or fingerprint. On iOS, delete and reinstall the app after changing the AASA file — the system caches it, and toggling Developer → Associated Domains Development in Settings makes iterating faster.

Add one integration test per critical link so the routing table can't silently regress:

testWidgets('deep link opens work order', (tester) async {
  final router = buildRouter(auth: FakeAuth.loggedIn());
  router.go('/work-orders/42');
  await tester.pumpWidget(MaterialApp.router(routerConfig: router));
  await tester.pumpAndSettle();
  expect(find.text('Work order 42'), findsOneWidget);
});

Checklist before you hand links to marketing

  1. Both verification files return 200, JSON content type, no redirect.
  2. Play App Signing fingerprint is in assetlinks.json.
  3. adb shell pm get-app-links reports verified on a release build.
  4. Cold start, warm start, and killed-app-from-notification all route correctly.
  5. Logged-out deep link survives the login round trip.
  6. Every deep-linkable route has a working web equivalent for desktop users.
  7. Unknown paths hit errorBuilder, not a crash.

Deep links are cheap to get right at the start of a project and expensive to retrofit once the URL scheme is public. If you want a second pair of eyes on your navigation architecture, or Flutter engineers who have shipped this on both stores, get in touch.