Skip to content
ExponaBLOG
FLUTTER · DAY 023 / 100

Deep links in Flutter: a practical guide for developers

Open the right screen from a link.

Expona Admin7 min read

Opening Hook

Deep links in Flutter: a practical guide for developers means the difference between users landing on the right screen and your app crashing on launch. Whether a user taps a notification, follows a link from email, or shares a URL with a friend, you need to route them to the exact place they expect without losing context or state. This guide walks through the complete setup—from platform configuration through testing—so your deep linking implementation handles both cold and warm app starts reliably.

Deep links point directly to content inside your app rather than the app's home screen. A notification might link to myapp://order/12345. An SMS might contain https://myapp.com/promo/summer-sale. Without proper handling, users see your home screen, confused and frustrated. With deep linking configured correctly, they land on the order detail screen or promotion page immediately.

Deep links come in two flavors: custom schemes like myapp:// and universal/app links using https://. Universal links are more secure and user-friendly because they look like regular web URLs. Most production apps use both to cover different scenarios.

Android App Links Setup

Android App Links require you to prove domain ownership. Start by creating an assetlinks.json file and hosting it at https://yourdomain.com/.well-known/assetlinks.json.

dart
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.myapp",
      "sha256_cert_fingerprints": ["AA:BB:CC:DD:..."] 
    }
  }
]

Find your app's SHA-256 fingerprint using:

dart
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

Next, update AndroidManifest.xml to declare the intent filter:

dart
<activity
    android:name=".MainActivity"
    android:exported="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="myapp.com" />
    </intent-filter>
</activity>

The android:autoVerify="true" attribute tells Android to verify the domain. Without it, the OS won't treat the links as direct app links—users will see a chooser dialog instead.

iOS Universal Links Setup

iOS requires an apple-app-site-association (AASA) file at https://yourdomain.com/.well-known/apple-app-site-association. Note: no file extension.

dart
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "ABCD123456.com.example.myapp",
        "paths": ["/order/*", "/promo/*"]
      }
    ]
  }
}

Find your Team ID and Bundle ID in Xcode, then combine them as TeamID.BundleID.

In Xcode, enable Associated Domains under your target's Signing & Capabilities:

dart
applinks:myapp.com

iOS will fetch and cache this file when your app first launches. Test by removing your app, clearing Safari data, then tapping a link in Safari. If the AASA file is malformed or unreachable, iOS silently falls back to web view behavior.

go_router Handles Incoming Paths

Use go_router to parse incoming deep links and navigate to the correct screen. It abstracts away platform-specific link handling and gives you declarative routing.

dart
final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/order/:orderId',
      builder: (context, state) {
        final orderId = state.pathParameters['orderId']!;
        return OrderDetailScreen(orderId: orderId);
      },
    ),
    GoRoute(
      path: '/promo/:promoCode',
      builder: (context, state) {
        final code = state.queryParameters['code'];
        return PromoScreen(code: code);
      },
    ),
  ],
);

Pass the router to your MaterialApp:

dart
MaterialApp.router(
  routerConfig: router,
  title: 'My App',
)

When a user taps https://myapp.com/order/12345, go_router matches it against your routes and builds the appropriate screen. If no route matches, you can define a fallback with GoRoute(path: '/:rest').

For custom schemes like myapp://order/12345, go_router still works because the operating system passes the URL to your app's `onGenerateRoute` callback. Configure go_router's initialRoute to handle the incoming link:

dart
final router = GoRouter(
  initialRoute: _getInitialRoute(),
  routes: [...],
);

You'll also need to handle the initial link at app startup and subsequent links while the app is running. Use uni_links or app_links plugins to listen for incoming links:

dart
StreamSubscription? _deepLinkSubscription;

@override
void initState() {
  super.initState();
  _deepLinkSubscription = 
    AppLinks().uriLinkStream.listen((uri) {
      router.go(uri.toString());
    });
}

@override
void dispose() {
  _deepLinkSubscription?.cancel();
  super.dispose();
}

Validate Parameters Before Loading Data

Never assume the incoming parameter is valid. A user might craft a malicious URL or follow an outdated link to a deleted order. Validate first, then load data.

dart
GoRoute(
  path: '/order/:orderId',
  builder: (context, state) {
    final orderId = state.pathParameters['orderId']!;
    
    // Validate format
    if (!RegExp(r'^\d+$').hasMatch(orderId)) {
      return const InvalidLinkScreen();
    }
    
    // Parse safely
    final id = int.tryParse(orderId);
    if (id == null) {
      return const InvalidLinkScreen();
    }
    
    return OrderDetailScreen(orderId: id);
  },
);

Inside your screen, load data and handle errors gracefully:

dart
class OrderDetailScreen extends StatelessWidget {
  final int orderId;
  
  const OrderDetailScreen({required this.orderId});
  
  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _fetchOrder(orderId),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const LoadingScreen();
        }
        if (snapshot.hasError) {
          return ErrorScreen(
            message: 'Order not found. Please check the link.',
          );
        }
        final order = snapshot.data as Order;
        return OrderContent(order: order);
      },
    );
  }
  
  Future<Order> _fetchOrder(int id) async {
    final response = await http.get(Uri.parse('/api/orders/$id'));
    if (response.statusCode == 404) {
      throw Exception('Order not found');
    }
    return Order.fromJson(jsonDecode(response.body));
  }
}

This approach prevents crashes and gives users clear feedback when a link is stale or malformed.

Test Cold Starts and Warm Starts

Deep links behave differently depending on whether your app is already running. Test both scenarios thoroughly.

Cold Start Testing

A cold start happens when your app isn't running. The OS launches it fresh and passes the deep link.

Android: Use adb to simulate a deep link:

dart
adb shell am start -a android.intent.action.VIEW \
  -d "https://myapp.com/order/12345" com.example.myapp

Kill your app completely, then run this command. Your app should launch and navigate directly to the order detail screen.

iOS: Use Xcode's scheme editor. Go to Product → Scheme → Edit Scheme, select Run, and set the URL in "Arguments Passed On Launch":

dart
myapp://order/12345

Or use xcrun from terminal:

dart
xcrun simctl openurl booted "https://myapp.com/order/12345"

The app should launch and land on the correct screen.

Warm Start Testing

A warm start occurs when your app is already running in the background. The OS sends the link without relaunching the app, and your app should update the UI in place.

Launch your app normally, navigate to the home screen, then send a deep link from another app or terminal. Your app should jump to the new screen without reloading everything.

Use the same adb and xcrun commands above, but don't kill your app first. Your app's link stream listener should receive the URL and call router.go().

Test State Preservation

Some deep links might preserve navigation state. For example, a promo link might land on a promo screen, but users should still be able to tap back and return to where they were. Test that your back button behavior is intuitive.

dart
GoRoute(
  path: '/promo/:code',
  builder: (context, state) {
    return PromoScreen(code: state.pathParameters['code']!);
  },
  pageBuilder: (context, state) {
    // Replace the entire history or preserve it?
    return CustomTransitionPage(
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        return child; // Slide in transition
      },
      child: PromoScreen(code: state.pathParameters['code']!),
    );
  },
);

Common Mistakes

  • Forgetting domain verification: Without assetlinks.json or apple-app-site-association, your app links won't work. Test from the Settings app under App Links (Android) or open links in Safari (iOS) to verify.
  • Not handling null parameters: Always check if a path parameter exists and parse it safely. Assume every deep link is potentially malicious or outdated.
  • Testing only in warm starts: Your app might work perfectly when already running but crash on cold start because initialization logic isn't set up. Always test both.
  • Ignoring query parameters: Don't forget about URL query strings. A link like https://myapp.com/order/12345?ref=email&utm_source=campaign carries extra context that you might want to log or display.
  • Hardcoding routes in multiple places: Define routes in one location (your GoRouter configuration) and reference them elsewhere. Hard-coding paths leads to mismatches and broken links.

Key Takeaways

  • Deep links require platform-specific configuration: assetlinks.json for Android and apple-app-site-association for iOS. Without them, universal links won't work reliably.
  • go_router simplifies deep link routing by parsing URLs declaratively and routing to the correct screen.
  • Always validate and parse incoming parameters before using them. Assume links might be stale, malformed, or crafted by users.
  • Test cold starts (app not running) and warm starts (app in background) separately. They behave differently and both are essential to your user experience.
  • Use query parameters to pass metadata like tracking codes or feature flags alongside your main route path.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles