Skip to content
Blog

Deep Linking Best Practices in Flutter: Testing, Fallbacks, and Attribution

A QA-first guide to Flutter deep linking: link inventories, unknown-route fallbacks, stack restoration, a cross-platform test matrix, and UTM attribution.

Published on • November 12, 2026

AI Assistant

Platform setup gets a link to open your app, but it does not get the user to the right screen, keep the back button sane, or tell you which campaign produced the tap. This post is the QA and strategy layer on top of the platform configuration: how to inventory links, what to render when a URL matches nothing, how to keep the navigation stack coherent, how to test every surface before release, and how to keep attribution parameters intact.

Treat every routable URL as a registered asset. A single table in the repo, reviewed in the same pull request as the route code, beats a wiki page that drifts:

PatternScreenOwnerVerified on
/products/:idProductPagecommerceAndroid, iOS, web
/orders/:idOrderScreencheckoutAndroid, iOS
/promo/:slugPromoScreengrowthAndroid, iOS, web

Apply one canonical scheme everywhere: lowercase paths, stable content IDs instead of titles, no trailing slash, and query parameters reserved for attribution and feature flags rather than routing. Routing on query strings is what makes a link impossible to grep for and impossible to test.

Unknown routes must render something useful

The Flutter navigation docs are explicit that named routes are no longer recommended, and that a declarative router always displays the same screens when a deep link arrives. A declarative router also has to answer “what if nothing matches”. go_router ships default error screens for MaterialApp and CupertinoApp, but you should own that screen, because a dead link is a marketing asset you still control:

final router = GoRouter(
  routes: [/* ... */],
  onException: (context, state, router) {
    // Log first: unmatched links are usually a routing bug or a stale campaign.
    debugPrint('unmatched deep link: ${state.uri}');
    router.go('/not-found?from=${Uri.encodeComponent(state.uri.toString())}');
  },
);

onException supersedes the older errorBuilder and errorPageBuilder callbacks, which still work if your app has not migrated. The fallback screen itself should offer three things: a way home, a search or browse entry point, and the original URL echoed back so a confused user can report it. Never return an empty Scaffold, and never let an exception escape into a blank canvas.

Preserve the stack instead of resetting it

Two rules cover most bugs. First, context.go('/path') replaces the location while context.push('/path') adds a route, so external links that should behave like “open this page” use go, and drill-in flows where back must return to the list use push. Second, from the Flutter navigation overview: routes created from Page objects are deep-linkable, pageless routes created by Navigator.push or dialogs are not, and removing a page-backed route also removes the pageless routes stacked above it.

Cold start differs by platform, which is why a single “read the initial route” test is not enough. Android hands your app an initialRoute that already contains the deep link path, while iOS delivers initialRoute of / and sends the deep link a moment later. Test both paths, and keep your shell routes (ShellRoute for persistent bottom navigation) so a deep link lands inside the shell rather than replacing it.

Deferred and stale content

Links outlive content. Handle the three common cases explicitly:

  • Content still loading: show a skeleton or progress state, then either the screen or a recovery screen. Do not block route resolution on the network call.
  • Content deleted or moved: resolve to a tombstone that names what happened and links to the closest live category, and on the web side issue a real redirect so old campaign URLs keep working.
  • Content behind auth: bounce through redirect with a from parameter, then continue to the original URL after sign-in so the attribution survives the login wall.
redirect: (context, state) {
  final loggedIn = session.isLoggedIn;
  if (!loggedIn && state.matchedLocation != '/login') {
    return '/login?from=${Uri.encodeComponent(state.uri.toString())}';
  }
  return null;
},

Cross-platform testing matrix

SurfaceCommand or toolProvesDoes not prove
Androidadb shell am start -a android.intent.action.VIEW -d "https://example.com/p/42"manifest and routingthat assetlinks.json is reachable
Android verificationadb shell pm verify-app-links --re-verify com.example.app then adb shell pm get-app-links com.example.appdomain verification statein-app rendering
iOS Simulatorxcrun simctl openurl booted https://example.com/p/42URL deliverythat Safari and Notes behave the same
iOS devicepaste the link into Notes and tap it, then long-press in SafariAASA fetch, entitlement, routingcold-start ordering on a reinstall
Web and QRopen the URL in a desktop browser, scan the QR with a phone camerapath URL strategy, redirect rulesnative app routing
Any platformwidget test with MyApp(initialLocation: '/p/42')router logic and fallbacksOS-level association
Both mobile OSesDevTools, Deep Links tab (works for Android and iOS since Flutter 3.27)website config against manifest or entitlementruntime behavior

The Flutter app-links cookbook warns that the adb command launches the app even when the web files are missing, so pair every adb test with one real tap through a browser or another app.

Attribution that survives the redirect

Campaign links arrive as ?utm_source=...&utm_medium=.... Keep them out of the route table but inside the navigation state: go_router matches on the path, so query parameters never break matching, and reading them in redirect before any bounce means the login wall does not swallow them.

redirect: (context, state) {
  final utm = {
    for (final key in ['utm_source', 'utm_medium', 'utm_campaign'])
      if (state.uri.queryParameters.containsKey(key))
        key: state.uri.queryParameters[key]!,
  };
  if (utm.isNotEmpty) pendingAttribution.merge(utm);
  return null;
},

Log the original incoming URI once per cold start rather than on every route change, or you will count the same campaign tap five times as the user navigates.

Pre-release checklist

  • Every routable URL appears in the inventory table, with an owner and a last-verified date.
  • Unknown URLs render the fallback screen on Android, iOS, and web, not an empty scaffold.
  • A deep link into a logged-out session returns to the original URL after sign-in.
  • Cold start and warm start are both tested on each platform, since iOS delivers the link late.
  • Back button from a deep-linked screen lands somewhere sensible, never on a black screen.
  • Deleted content has a tombstone or redirect instead of a crash or a spinner.
  • utm_* parameters are captured before any redirect and reported once per session.
  • The DevTools Deep Links tab reports no configuration errors for release builds.

Takeaway: Deep linking quality is a QA discipline, not a manifest flag, so keep one canonical link inventory, render a real fallback for unmatched URLs, preserve the stack with go versus push, test every surface with the right command, and capture attribution before your redirects can strip it.

Sources