Navigation & Routing

Deep Linking and Platform Configuration

16 min Lesson 13 of 14

Deep Linking and Platform Configuration

Deep linking allows external URLs to open specific screens inside your Flutter app. A user tapping a link like myapp://profile/42 or https://myapp.com/products/99 lands directly on the relevant screen rather than the home page. This lesson covers how to configure both Android and iOS for deep links, wire those links into GoRouter, and test the full flow on real devices and emulators.

What Is a Deep Link?

A deep link is a URI that routes a user into a specific location within a mobile app. There are three common deep-link flavours in Flutter:

  • Custom-scheme URIs — e.g. myapp://products/42. Simple to configure but not clickable on the web.
  • Android App Links — HTTPS URLs verified against your domain via Digital Asset Links. Android opens the app instead of the browser when verification passes.
  • iOS Universal Links — HTTPS URLs associated with your domain via an apple-app-site-association (AASA) file. iOS routes verified links directly to the app.
Note: For production apps always prefer App Links / Universal Links over custom schemes. Custom schemes can be intercepted by other apps on the device; HTTPS-based links are owned by your domain and cannot be hijacked.

Android Configuration — Intent Filters

Android uses intent filters declared in AndroidManifest.xml to describe which URIs the app can handle. Open android/app/src/main/AndroidManifest.xml and add the filter inside your main <activity> element.

android/app/src/main/AndroidManifest.xml — custom scheme + App Links

<!-- Inside <activity android:name=".MainActivity" ...> -->

<!-- Custom scheme: myapp://... -->
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" />
</intent-filter>

<!-- Android App Links: https://myapp.com/... -->
<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>

The android:autoVerify="true" attribute tells Android to verify ownership of myapp.com by fetching https://myapp.com/.well-known/assetlinks.json. That file must list your app's SHA-256 certificate fingerprint.

iOS Configuration — URL Schemes and Associated Domains

For iOS, two things must be configured in Xcode (or directly in the source files):

  • Custom URL scheme — add a CFBundleURLTypes entry to ios/Runner/Info.plist.
  • Universal Links — enable the Associated Domains capability and add applinks:myapp.com.

ios/Runner/Info.plist — custom URL scheme

<!-- Add inside the root <dict> -->
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLName</key>
        <string>com.example.myapp</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>myapp</string>
        </array>
    </dict>
</array>

For Universal Links, open Xcode → Runner → Signing & Capabilities → + Capability → Associated Domains and add applinks:myapp.com. This generates an entitlements entry. The AASA file must be hosted at https://myapp.com/.well-known/apple-app-site-association.

Tip: The flutter_deep_link or go_router package documentation links to Apple's and Google's validators. Use the App Links Assistant in Android Studio and Apple's AASA Validator at branch.io/resources/aasa-validator to check your server files before shipping.

Handling Deep Links with GoRouter

GoRouter integrates with Flutter's Router API which automatically receives deep-link URIs from the platform. You only need to ensure your route paths match the incoming URI segments. GoRouter's initialLocation sets the default screen when the app cold-starts without a deep link.

main.dart — GoRouter with deep-link-ready routes

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

final _router = GoRouter(
  initialLocation: '/home',           // Default on cold start
  debugLogDiagnostics: true,          // Log all navigation events
  routes: [
    GoRoute(
      path: '/home',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/products/:id',          // :id matches the path segment
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return ProductDetailScreen(productId: id);
      },
    ),
    GoRoute(
      path: '/profile/:userId',
      builder: (context, state) {
        final userId = state.pathParameters['userId']!;
        final tab = state.uri.queryParameters['tab'] ?? 'posts';
        return ProfileScreen(userId: userId, initialTab: tab);
      },
    ),
  ],
);

void main() {
  runApp(
    MaterialApp.router(
      routerConfig: _router,
      title: 'Deep Link Demo',
    ),
  );
}

When Android fires the intent https://myapp.com/products/99, Flutter's engine strips the host and passes /products/99 to GoRouter, which matches the /products/:id route and builds ProductDetailScreen(productId: '99'). Query parameters are accessible via state.uri.queryParameters.

Redirects and Authentication Guards

Deep links bypass your normal navigation flow, so you must guard authenticated routes. GoRouter's redirect callback runs before any route is built, making it the correct place to enforce auth:

GoRouter redirect — protecting deep-linked routes

final _router = GoRouter(
  initialLocation: '/home',
  redirect: (BuildContext context, GoRouterState state) {
    final isLoggedIn = AuthService.instance.isLoggedIn;
    final isOnLogin  = state.matchedLocation == '/login';

    // Not logged in and not heading to login → send to login
    if (!isLoggedIn && !isOnLogin) return '/login';

    // Already logged in and heading to login → send home
    if (isLoggedIn && isOnLogin) return '/home';

    // No redirect needed
    return null;
  },
  routes: [
    GoRoute(path: '/login',   builder: (c, s) => const LoginScreen()),
    GoRoute(path: '/home',    builder: (c, s) => const HomeScreen()),
    GoRoute(
      path: '/orders/:id',
      builder: (c, s) => OrderScreen(orderId: s.pathParameters['id']!),
    ),
  ],
);
Warning: Without a redirect guard, a deep link to /orders/123 will render the order screen even when the user is logged out. Always validate auth state (and other preconditions) in the redirect callback for every protected route.

Testing Deep Links

You can trigger deep links on emulators and physical devices from the command line without a browser:

  • Android (adb): adb shell am start -a android.intent.action.VIEW -d "myapp://products/42" com.example.myapp
  • iOS (xcrun): xcrun simctl openurl booted "myapp://products/42"
  • Flutter DevTools: The Deep Links tab (Flutter 3.19+) validates your manifest entries and lets you fire test URIs directly.
Tip: Enable debugLogDiagnostics: true on your GoRouter instance during development. Every incoming URI, matched route, and redirect is printed to the console, making it trivial to trace why a link landed on the wrong screen.

Summary

Deep linking connects the web and native worlds. On Android, intent filters in AndroidManifest.xml declare which URIs open your app; App Links add domain verification for HTTPS links. On iOS, Info.plist registers custom schemes and Associated Domains enables Universal Links. GoRouter handles incoming URIs automatically — your only job is to ensure path patterns match and that redirect guards protect sensitive routes. Always test with adb or xcrun simctl before shipping.