Development

Deep Linking in Flutter: Universal Links, App Links & GoRouter

Muhammad Shakil Muhammad Shakil
Apr 06, 2026
17 min read
Deep linking in Flutter with Universal Links and App Links
Back to Blog

A user taps a link in an email: https://yourapp.com/product/42. What happens? If deep linking is set up correctly, your Flutter app opens directly to that product page. If it's not, the user lands on your website — or worse, a blank screen and back to the home page. Deep linking is the difference between a seamless user experience and a frustrating one.

I've set up deep linking on over a dozen Flutter projects. The routing part is straightforward — GoRouter handles that in a few lines. The tricky part is the platform configuration: verification files, entitlements, intent filters, and the dozen small things that break silently. This guide covers every step, from creating your verification files to handling edge cases like authenticated routes and deferred deep links.

What You'll Learn

Before writing code, you need to understand the three types of deep links and when to use each:

URI Scheme Links (Custom Scheme)

Format: myapp://product/123

These use a custom protocol. Any app can register any scheme — there's no ownership verification. If two apps register myapp://, the OS shows a chooser dialog on Android or picks one unpredictably on iOS. Use these only during development or for app-to-app communication you control.

App Links (Android)

Format: https://yourapp.com/product/123

These are standard HTTPS URLs verified through a file hosted on your domain (assetlinks.json). Because the domain proves ownership, Android opens your app directly — no chooser dialog. If the app isn't installed, the link opens in the browser.

Universal Links (iOS)

Format: https://yourapp.com/product/123

Same concept as App Links, but Apple's implementation. Verified through apple-app-site-association hosted on your domain. iOS opens your app directly when it's installed. If not, Safari opens the URL.

Feature URI Scheme App Links (Android) Universal Links (iOS)
Format myapp://path https://domain/path https://domain/path
Ownership verified No Yes (assetlinks.json) Yes (AASA file)
Requires server No Yes Yes
Fallback without app Error Opens in browser Opens in Safari
Production recommended No Yes Yes

Step 1: Add Intent Filters to AndroidManifest.xml

Open android/app/src/main/AndroidManifest.xml and add intent filters inside the <activity> tag:

<activity
    android:name=".MainActivity"
    android:launchMode="singleTop">

    <!-- Deep link intent filter -->
    <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="yourapp.com"
            android:pathPrefix="/product" />
    </intent-filter>

    <!-- Handle all paths under your domain -->
    <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="yourapp.com" />
    </intent-filter>

</activity>

The android:autoVerify="true" attribute tells Android to verify your domain ownership at install time.

Step 2: Host the assetlinks.json File

Create a file at https://yourapp.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.yourcompany.yourapp",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
      ]
    }
  }
]

Get your SHA-256 fingerprint with:

# Debug key
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android

# Release key
keytool -list -v -keystore your-release-key.jks -alias your-alias

Include BOTH debug and release fingerprints in the file during development. Remove the debug fingerprint before production release.

Step 1: Add Associated Domains Entitlement

Open ios/Runner/Runner.entitlements (create it if it doesn't exist) and add:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:yourapp.com</string>
        <string>applinks:yourapp.com?mode=developer</string>
    </array>
</dict>
</plist>

The ?mode=developer variant lets you test without Apple caching your AASA file. Remove it for production.

Step 2: Enable Associated Domains in Xcode

Open ios/Runner.xcworkspace in Xcode. Go to Runner target → Signing & Capabilities → + Capability → Associated Domains. Add applinks:yourapp.com.

Step 3: Host the apple-app-site-association File

Create a file at https://yourapp.com/.well-known/apple-app-site-association (no file extension):

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appIDs": ["TEAMID.com.yourcompany.yourapp"],
        "components": [
          { "/": "/product/*", "comment": "Product pages" },
          { "/": "/user/*", "comment": "User profiles" },
          { "/": "/settings", "comment": "Settings page" }
        ]
      }
    ]
  }
}

Replace TEAMID with your Apple Developer Team ID. You can find it in the Apple Developer portal under Membership.

Apple Caches This File

Apple's CDN caches your AASA file for up to 24 hours. If you update it, changes won't take effect immediately. During development, use the ?mode=developer associated domain entry to bypass caching. On device, go to Settings → Developer → Universal Links → Diagnostics to force a refresh.

With the platform side configured, handle routing in Flutter. GoRouter maps URL paths to screens and handles deep links automatically — no extra plugins needed.

// pubspec.yaml
dependencies:
  go_router: ^14.8.0
import 'package:go_router/go_router.dart';

final router = GoRouter(
  initialLocation: '/',
  debugLogDiagnostics: true, // Remove in production
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/product/:id',
      builder: (context, state) {
        final productId = state.pathParameters['id']!;
        return ProductScreen(productId: productId);
      },
    ),
    GoRoute(
      path: '/user/:username',
      builder: (context, state) {
        final username = state.pathParameters['username']!;
        return UserProfileScreen(username: username);
      },
    ),
    GoRoute(
      path: '/search',
      builder: (context, state) {
        final query = state.uri.queryParameters['q'] ?? '';
        return SearchScreen(initialQuery: query);
      },
    ),
  ],
);

// In your MaterialApp
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
    );
  }
}

When a user taps https://yourapp.com/product/42, GoRouter receives /product/42, matches it against the /product/:id route, extracts 42 as the id parameter, and renders ProductScreen(productId: '42').

Real-world deep links often include multiple parameters and query strings. Here's how to handle them:

GoRoute(
  // Matches: /category/electronics/product/42
  path: '/category/:categoryId/product/:productId',
  builder: (context, state) {
    final categoryId = state.pathParameters['categoryId']!;
    final productId = state.pathParameters['productId']!;
    // Query params: ?ref=email&campaign=summer
    final ref = state.uri.queryParameters['ref'];
    final campaign = state.uri.queryParameters['campaign'];

    // Track attribution
    if (ref != null) {
      analytics.trackDeepLink(ref: ref, campaign: campaign);
    }

    return ProductScreen(
      categoryId: categoryId,
      productId: productId,
    );
  },
),

Validating Parameters

Never trust incoming deep link parameters. They come from outside your app — treat them like user input:

GoRoute(
  path: '/product/:id',
  redirect: (context, state) {
    final id = state.pathParameters['id'];
    // Validate: must be numeric and reasonable
    if (id == null || int.tryParse(id) == null || int.parse(id) < 0) {
      return '/'; // Redirect invalid links to home
    }
    return null; // Valid — proceed to builder
  },
  builder: (context, state) {
    final productId = int.parse(state.pathParameters['id']!);
    return ProductScreen(productId: productId);
  },
),

6. Deep Linking into Nested Navigation

Most apps have tabbed navigation. Deep linking should land users on the correct tab with the right sub-page. GoRouter's ShellRoute handles this:

final router = GoRouter(
  initialLocation: '/home',
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return MainShell(navigationShell: navigationShell);
      },
      branches: [
        // Tab 0: Home
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/home',
              builder: (context, state) => const HomeScreen(),
              routes: [
                GoRoute(
                  path: 'notifications',
                  builder: (context, state) => const NotificationsScreen(),
                ),
              ],
            ),
          ],
        ),
        // Tab 1: Search
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/search',
              builder: (context, state) => const SearchScreen(),
            ),
          ],
        ),
        // Tab 2: Profile
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/profile',
              builder: (context, state) => const ProfileScreen(),
              routes: [
                GoRoute(
                  path: 'settings',
                  builder: (context, state) => const SettingsScreen(),
                ),
                GoRoute(
                  path: 'edit',
                  builder: (context, state) => const EditProfileScreen(),
                ),
              ],
            ),
          ],
        ),
      ],
    ),
  ],
);

Now https://yourapp.com/profile/settings opens the Profile tab and pushes the Settings screen on top. The tab state persists — switching to Home and back to Profile shows Settings still on the stack.

7. Authentication Guards for Deep Links

What happens when a user deep links to /profile/settings but isn't logged in? You need a redirect that saves the intended destination:

final router = GoRouter(
  redirect: (context, state) {
    final authState = context.read<AuthCubit>().state;
    final isLoggedIn = authState is Authenticated;
    final isLoginRoute = state.matchedLocation == '/login';

    // Paths that don't require auth
    const publicPaths = ['/', '/login', '/signup', '/product'];
    final isPublicPath = publicPaths.any(
      (path) => state.matchedLocation.startsWith(path),
    );

    if (!isLoggedIn && !isPublicPath) {
      // Save intended destination for post-login redirect
      return '/login?redirect=${Uri.encodeComponent(state.uri.toString())}';
    }

    if (isLoggedIn && isLoginRoute) {
      // Already logged in, redirect to intended page or home
      final redirect = state.uri.queryParameters['redirect'];
      return redirect != null ? Uri.decodeComponent(redirect) : '/home';
    }

    return null; // No redirect needed
  },
  routes: [/* ... */],
);

After login, read the redirect query parameter and navigate there. The user lands exactly where they intended — even if they had to authenticate first.

Deferred deep linking handles this flow: user clicks a link → app isn't installed → user goes to the app store → installs the app → app opens to the intended content, not just the home screen.

Flutter doesn't have built-in support for deferred deep links. Here's a lightweight approach without third-party services:

// On your web server: landing page redirects to store
// but stores the intended path in a server-side session
// keyed by a unique ID passed as a query parameter

// In your Flutter app: on first launch, check for pending deep link
class DeferredDeepLinkService {
  final Dio _dio;
  final SharedPreferences _prefs;

  DeferredDeepLinkService(this._dio, this._prefs);

  Future<String?> checkPendingDeepLink() async {
    // Only check once after install
    if (_prefs.getBool('deferred_link_checked') == true) return null;

    try {
      // Your server returns the stored deep link for this device/install
      final response = await _dio.get('/api/deferred-link', queryParameters: {
        'device_id': await _getDeviceId(),
      });

      await _prefs.setBool('deferred_link_checked', true);

      if (response.statusCode == 200 && response.data['path'] != null) {
        return response.data['path'] as String;
      }
    } catch (_) {
      // Network error — skip, don't block app launch
    }
    return null;
  }
}

For a production-grade solution, consider Branch.io or Adjust — they handle the attribution, device fingerprinting, and store redirect logic.

On Flutter Web, deep linking "just works" because your app is already running in a browser. The URL in the address bar IS the deep link. But there are configuration details that trip people up:

// Use PathUrlStrategy for clean URLs (no hash fragment)
import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  usePathUrlStrategy(); // https://yourapp.com/product/42
  // Instead of default:      https://yourapp.com/#/product/42
  runApp(const MyApp());
}

Server-Side Configuration for Path URLs

With usePathUrlStrategy(), your server needs to return index.html for all routes — otherwise refreshing /product/42 returns a 404. Configure your web server:

# Nginx
location / {
    try_files $uri $uri/ /index.html;
}

# Firebase Hosting (firebase.json)
{
  "hosting": {
    "rewrites": [
      { "source": "**", "destination": "/index.html" }
    ]
  }
}

Android (adb)

# Test App Link
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourapp.com/product/42" \
  com.yourcompany.yourapp

# Verify App Links are set up correctly
adb shell pm get-app-links com.yourcompany.yourapp

iOS Simulator (xcrun)

# Test Universal Link
xcrun simctl openurl booted "https://yourapp.com/product/42"

Widget Tests with GoRouter

testWidgets('Deep link to product page', (tester) async {
  final router = GoRouter(
    initialLocation: '/product/42',
    routes: [
      GoRoute(
        path: '/',
        builder: (context, state) => const HomeScreen(),
      ),
      GoRoute(
        path: '/product/:id',
        builder: (context, state) {
          final id = state.pathParameters['id']!;
          return ProductScreen(productId: id);
        },
      ),
    ],
  );

  await tester.pumpWidget(
    MaterialApp.router(routerConfig: router),
  );
  await tester.pumpAndSettle();

  // Product screen should be showing
  expect(find.byType(ProductScreen), findsOneWidget);
  expect(find.text('Product #42'), findsOneWidget);
});

testWidgets('Invalid deep link redirects to home', (tester) async {
  final router = GoRouter(
    initialLocation: '/product/abc', // Invalid ID
    routes: [
      GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
      GoRoute(
        path: '/product/:id',
        redirect: (_, state) {
          if (int.tryParse(state.pathParameters['id'] ?? '') == null) {
            return '/';
          }
          return null;
        },
        builder: (_, state) =>
            ProductScreen(productId: state.pathParameters['id']!),
      ),
    ],
  );

  await tester.pumpWidget(MaterialApp.router(routerConfig: router));
  await tester.pumpAndSettle();

  expect(find.byType(HomeScreen), findsOneWidget);
});

11. Troubleshooting Common Issues

Deep linking has a lot of moving parts. Here are the issues I run into most often:

App Links Not Verified on Android

Symptom: The chooser dialog appears instead of opening your app directly.

Fix: Check that assetlinks.json is accessible at https://yourdomain.com/.well-known/assetlinks.json. It must be served with Content-Type: application/json and must not have redirects. Run adb shell pm get-app-links your.package.name — the status should show verified. If it shows legacy_failure, your SHA-256 fingerprint doesn't match.

Universal Links Open in Safari Instead of the App

Symptom: Tapping a link opens Safari, not your app.

Fix: Universal Links don't work when you type the URL directly in Safari's address bar or when you tap a link on the same domain. They work from other apps (Messages, Mail, Notes, third-party apps). Also check that your apple-app-site-association file is valid JSON and served over HTTPS without redirects. Use Apple's AASA Validator to verify.

GoRouter Doesn't Receive the Deep Link

Symptom: Platform verification works, app opens, but it shows the home screen.

Fix: Make sure you're using MaterialApp.router with routerConfig: router, not MaterialApp with home:. Check that your GoRouter route paths match the URL paths in your intent filters / AASA components. Enable debugLogDiagnostics: true to see which routes GoRouter is matching.

Deep Link Works on First Launch but Not on Subsequent Taps

Symptom: Deep link works when app is cold-started, but not when it's already running in the background.

Fix: Ensure your Android activity has android:launchMode="singleTop" in the manifest. Without it, Android creates a new activity instance instead of routing to the existing one. GoRouter handles the onNewIntent callback automatically.

Related Guides

Frequently Asked Questions

What's the difference between deep links and URI scheme links in Flutter?

URI scheme links (myapp://product/123) are custom protocols that only work if the app is installed and can't be verified for ownership. Deep links using Universal Links (iOS) and App Links (Android) use HTTPS URLs with domain verification — only the developer who controls the domain can handle those links. Always prefer Universal/App Links in production.

Do I need a server to set up deep linking?

Yes, for Universal Links and App Links. iOS requires an apple-app-site-association file at your-domain.com/.well-known/apple-app-site-association, and Android requires an assetlinks.json at your-domain.com/.well-known/assetlinks.json. These files prove you own the domain and authorize your app to handle its URLs. Any web server, CDN, or Firebase Hosting works.

Does GoRouter handle deep links automatically?

GoRouter handles the routing part — mapping a URL path to a screen. But you still need to configure the platform side: Universal Links on iOS, App Links on Android, and verification files on your server. GoRouter receives the deep link URI and matches it against your route definitions, but it won't handle the platform handshake.

How do I test deep links without deploying?

On Android, use adb shell am start -a android.intent.action.VIEW -d 'https://yourdomain.com/product/123' com.your.package. On iOS simulator, use xcrun simctl openurl booted 'https://yourdomain.com/product/123'. For GoRouter, write widget tests that set the initial location to any deep link path and verify the correct screen renders.

What about deferred deep links for users who don't have the app installed?

Deferred deep linking means a user clicks a link, gets redirected to the app store, installs the app, and then lands on the intended content — not the home screen. Flutter doesn't have built-in support for this. You need a service like Branch.io or Adjust, or build a lightweight server-side solution that stores the intended URL and retrieves it on first app launch.

Can I deep link into specific tabs or nested navigation?

Yes. With GoRouter's StatefulShellRoute, you define routes that map to specific tabs. Each tab can have its own nested navigation stack. For example, /profile/settings opens the Profile tab and pushes Settings on top. The key is structuring your GoRouter route tree to match your navigation hierarchy.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.