Implementing Deep Links in Flutter: A Comprehensive Guide to Enhance Mobile User Experience

Samuel Abada
ITNEXT
Published in
11 min readJul 13, 2023

--

Deep linking in flutter

Deep links are links that send users directly to an app instead of a store or website. They are used to send users to specific locations in an app or display specific content to the user. Deep linking is the idea of having a clickable link to open up your app and a smart one that will also navigate to the desired resource. It is a shortcut to app content/resources to save time and energy locating a particular page, improving the user experience. Whether seamlessly transitioning from an email to a specific product page, smoothly launching a ride-sharing app with a destination pre-filled, or effortlessly sharing content across apps, deep linking is the secret sauce that makes it all possible.

Let’s create an imaginary product called Shoppy, an online store. Shoppy has a website and also a mobile app. If a user comes across a product of Shoppy either via search or a promotion link, by default, the link opens in the browser and routes to the exact product, showing you all the details. Employing deep linking here says you should send the user to the specific product page on the app.

Deep links send mobile device users directly to the specific page in-app instead of a website. A deep link is always in the format:
scheme://host/path
https://shoppy.com/product

On the web, most links are naturally deep links, as URLs on the web allow direct manipulation of applications. This is different on mobile, which requires some extra setup.

Why deep links

In product, we talk about user acquisition and retention. Deep links are a way to:

  • Drive product engagement
  • Post-install retention
  • Increase traffic to your apps
  • Personalized app experience
  • Drive engagement

Types of deep links

There are various types of deep links that serve different purposes and functionalities and cater to different scenarios. Understanding the various types of deep links empowers developers and marketers to leverage their capabilities and create engaging user experiences. By utilizing the appropriate deep-linking strategy, businesses can seamlessly connect their web and app ecosystems, enhance user engagement, and unlock the full potential of mobile experiences.

Universal link(Uni links)

This type is exclusive to Apple devices(iOS only) and uses HTTP or HTTPS as their scheme. They bridge the gap between the web and apps. They utilize standard web URLs to open the associated app directly if installed, eliminating the need for web browser intermediaries and providing a more seamless user experience.

App links

These are similar to uni links; however, they work on Android. Its mode of operation mirrors that of uni links, and they can be seen as equivalent to each other in their respective platforms.

Custom scheme

They utilize unique URL schemes specific to an app. They allow developers to define their scheme, such as myapp://, and trigger specific actions within the app. Custom scheme links provide a straightforward way to deep-link specific app functionality.

Deferred deep links

Deferred deep links redirect the user to the respective app store when clicked, whenever the user doesn't have the app installed instead of leading to a dead-end or the web. Once the app is installed and opened, the content related to the link is displayed. The original action is deferred until the app is installed and opened.

Generally, it is recommended to implement uni links on iOS and app links on Android over other types of deep links like custom schemes. Some of the reasons for adopting app and uni links over custom schemes include:

  • More security: They are more secure and specific as you verify domain ownership; hence your domain is associated with your app.
  • Better user experience: Since app links and uni links have the same URLs as your web links, users would be directed to the web if the app cannot open hence a seamless experience.

However, custom schemes might be a good fit, especially when your website and app do not match each other. However, anyone can claim any custom scheme as no authorisation is required; redirecting traffic(users) to apps other than yours makes it less secure. What happens when a user clicks on a custom scheme deep link but does not have your app installed? You guessed right; they are greeted by an error page.

Implementing deep links in Flutter

For this writeup, we will consider app links and uni links.

There are 2 parts to setting up app links and uni links — a web setup and an app setup.

Web setup

This requires you to own a web domain and host specific web files for Android and ios. For Android, we host the file assetlinks.json , while for ios we host the file apple-app-site-association . This part focuses on redirecting users/traffic from your website to the installed app on mobile devices. This web file contains information like the package name, app id, team id and/or sha256 fingerprint(for Android). This ensures that no other app can hijack redirect requests.

For Android web setup, follow these steps:

  • Get your app sha256 fingerprint. If your app was signed using google play app signing, you could get the fingerprint from your Play console. For locally generated key, you can get your sha256 fingerprint with the command:
    keytool -list -v -keystore <path-to-keystore>
  • Host the file assetlinks.json at a URL that resembles the following: <webdomain>/.well-known/assetlinks.json with content mirroring the JSON shown below. Replace the package name with your Android application id, the sha256 fingerprint here, with the one generated in the previous step.
  • Verify that your browser can access this file.
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "xyz.hephwinglabs.deepa",
"sha256_cert_fingerprints": [
"FF:2A:CF:7B:DD:CC:F1:03:3E:E8:B2:27:7C:A2:E3:3C:DE:13:DB:AC:8E:EB:3A:B9:72:A1:0E:26:8A:F5:EC:AF"
]
}
}
]

For iOS web setup, follow these steps:

  • Locate the team id of your app in the developer account. Also, locate the bundle id of your app in Xcode. The combination of the team ID and bundle id in the format <teamid>.<bundleid> gives the app id.
  • Host the file apple-app-site-association at a URL that resembles the following: <webdomain>/.well-known/apple-app-site-association with content mirroring the JSON shown below. Replace the app id with the correct value.
  • Set the value of the paths to [“*”]. The paths field specifies the allowed universal links. Using the asterisk * redirects every path to the Flutter application. If needed, change the value of the paths to a setting more appropriate to your app.
  • Verify that your browser can access this file.
{
"applinks": {
"apps": [],
"details": [
{
"appID": "teamID.xyz.hephwinglabs.deepa",
"paths": [ "*" ]
}
]
}
}

App setup

Here, we make changes to the necessary platform with certain parameters to specify what scheme and domain/host can redirect users to your app and write code to handle redirects from your website to the respective content/resource. Code changes here ensure your app navigates accordingly to the specified portion of your app as directed by the deep link.

There is a library called uni links on pub.dev that helps with App/Deep Links (Android), Universal Links (iOS), and Custom URL schemes. You could use that.

However, we will opt into Flutter’s default deep link handler. You can also migrate from plugin-based deep linking to Flutter’s default handler

For Android app setup, follow these steps:

  • Navigate to android/app/src/main/AndroidManifest.xml file
  • Add the below metadata tag inside the <activity> tag with .MainActivity to opt into Flutter’s default deep link handler. To continue using 3rd party packages or custom solutions, skip this step.
    <meta-data android:name=”flutter_deeplinking_enabled” android:value=”true” />
  • Add the below intent filter inside the <activity> tag with .MainActivity. Ensure to replace hephwinglabs.xyz with your domain name.
<meta-data android:name="flutter_deeplinking_enabled" android:value="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="http" android:host="hephwinglabs.xyz" />
<data android:scheme="https" />
</intent-filter>

For iOS app setup, follow these steps:

  • Launch XCode and open the ios/Runner.xcworkspace file inside the project’s ios folder
  • Navigate to the Info.plist file in the ios/Runner folder and add the property FlutterDeepLinkingEnabled with type Boolean whose value is set to YES.
FlutterDeepLinkingEnabled
  • Click the top-level Runner and select Sign & Capabilities . Create a new capability called. Associated Domains
Associated Domains
  • Add a domain by clicking the + button with the value applinks:<web domain>. Replace <web domain> with your domain name. Example: applinks:hephwinglabs.xyz

That concludes the platform-related app setup for app links and uni links. We are left with code related setup to navigate/route to the desired page/resource.

I have created a demo app available on github as a guide for code-related setup. A flutter app might use the navigator API(navigation 1.0) or the router API(navigation 2.0) for routing/navigation needs. Both cases are handled in the sample.

Ideally, we need to implement a routing table. With the navigator API, we can do this either with the routes parameter or onGenerateRoute and launch the URL using named routing. With the router API, we achieve the same with the router widget.

Router API/Navigation 2.0

I have chosen to use Routemaster as the routing package for this demo. The approach remains the same irrespective of the routing package you are using, or you go low level and interact with the router API without a dependency.

/// This file defines names for views in this project used as named routes.
/// These names are constant strings and should be defined in the format
///
/// ``` const String *viewName*ViewRoute = 'unique_route_name'; ```
const String homeViewRoute = '/';
const String productViewRoute = '/product';
const String productDetailsViewRoute = '/product/:id';
const String ordersViewRoute = '/orders';
const String supportViewRoute = '/support';
const String profileViewRoute = '/profile';
const String searchViewRoute = '/search';
const String notFoundViewRoute = '/404';
const String noAuthViewRoute = '/no-auth';

We have defined all app routes to their corresponding string to avoid passing string/path literals to my route map.

final routemaster = RoutemasterDelegate(
routesBuilder: (context) {
final state = Provider.of<AppState>(context);
developer.log('State: ${state.isLoggedIn}', name: 'State Change');
return RouteMap(
onUnknownRoute: (_) => const Redirect('/404'),
routes: {
homeViewRoute: (_) => const CupertinoTabPage(
child: HomePage(),
paths: ['/product', '/search', '/orders', '/support', '/profile'],
),
productViewRoute: (_) => const MaterialPage(child: ProductListing()),
productDetailsViewRoute: (info) =>
MaterialPage(child: ProductDetails(id: info.pathParameters['id'])),
searchViewRoute: (_) => const MaterialPage(child: SearchPage()),
ordersViewRoute: (_) => state.isLoggedIn
? const MaterialPage(child: OrdersPage())
: const MaterialPage(child: NotAuthPage()),
supportViewRoute: (_) => const MaterialPage(child: SupportPage()),
profileViewRoute: (_) => state.isLoggedIn
? const MaterialPage(child: ProfilePage())
: const MaterialPage(child: NotAuthPage()),
noAuthViewRoute: (_) => const MaterialPage(child: NotAuthPage()),
notFoundViewRoute: (_) => const MaterialPage(child: NotFoundPage()),
},
);
},
);

We have defined the route map. The class AppState as seen in the sample, can be removed as it exists as a sort of route guard for protected routes.

Now we pass our route delegate and information parser to the router constructor of the material app.

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AppState.instance,
child: MaterialApp.router(
title: 'Flutter Demo',
theme: ThemeData(
useMaterial3: true,
),
routerDelegate: routemaster,
routeInformationParser: const RoutemasterParser(),
),
);
}
}

To test the setup, we do the following:

Android: Try the command below in your terminal. It is expected to open up the product details screen with the id 678. Ensure to replace web-domain with the configured domain.
adb shell ‘am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d “http://<web-domain>/product/678”’ \
<package name>

iOS: Try the command below in your terminal. It is expected to open up the product details screen with the id 678 . Ensure to replace web-domain with the configured domain.
xcrun simctl openurl booted https://<web domain>/product/678

Navigator API/Navigation 1.0

Similar to what was described in the Router API above, we define all app routes to their corresponding string to avoid passing string/path literals to my route map.

We also define our route map as shown below. The class AppState as seen in the sample, can be removed as it exists as a sort of route guard for protected routes.

routes: <String, WidgetBuilder>{
homeViewRoute: (BuildContext context) => const HomeView(),
productViewRoute: (BuildContext context) => const ProductListing(),
searchViewRoute: (BuildContext context) => const SearchPage(),
ordersViewRoute: (BuildContext context) =>
context.read<AppState>().isLoggedIn
? const OrdersPage()
: const NotAuthPage(),
supportViewRoute: (BuildContext context) => const SupportPage(),
profileViewRoute: (BuildContext context) =>
context.read<AppState>().isLoggedIn
? const ProfilePage()
: const NotAuthPage(),
notFoundViewRoute: (BuildContext context) => const NotFoundPage(),
noAuthViewRoute: (BuildContext context) => const NotAuthPage(),
}

One would realize we did not define the product details route as part of the route map in the routes table. We want to make certain computations from the route settings, like getting the path arguments, which can only be done on generate route. It should be noted that the full route map can also be defined in the on generate route; however, I didn't do that to not over-flog the on generate route.

final router = RegexRouter.create({
// Access "object" arguments from `NavigatorState.pushNamed`.
productDetailsViewRoute: (context, args) => ProductDetails(id: args["id"]),
});


onGenerateRoute: (settings) {
developer.log(settings.name ?? 'No name', name: 'Route Name');
return router.generateRoute(settings);
},

Above, I have a router that uses regex to get the path args. I leveraged the package RegexRouter to do that; I mean, why rewrite a layer for regex if it exists 😏. Packages like Fluro also work great.

void main() {
runApp(const MyApp());
}

class MyApp extends StatefulWidget {
const MyApp({super.key});

@override
State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
AppState.instance.addListener(() {setState(() {

});});
}
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AppState.instance,
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
routes: <String, WidgetBuilder>{
homeViewRoute: (BuildContext context) => const HomeView(),
productViewRoute: (BuildContext context) => const ProductListing(),
searchViewRoute: (BuildContext context) => const SearchPage(),
ordersViewRoute: (BuildContext context) =>
context.read<AppState>().isLoggedIn
? const OrdersPage()
: const NotAuthPage(),
supportViewRoute: (BuildContext context) => const SupportPage(),
profileViewRoute: (BuildContext context) =>
context.read<AppState>().isLoggedIn
? const ProfilePage()
: const NotAuthPage(),
notFoundViewRoute: (BuildContext context) => const NotFoundPage(),
noAuthViewRoute: (BuildContext context) => const NotAuthPage(),
},
onUnknownRoute: (settings) => MaterialPageRoute(
settings: settings, builder: (_) => const NotFoundPage()),
onGenerateRoute: (settings) {
developer.log(settings.name ?? 'No name', name: 'Route Name');
return router.generateRoute(settings);
},
),
);
}
}

That concludes the navigation 1.0-related setup. To test our implementation, it is similar to what was described in the router API above.

Android: Try the below in your terminal. It is expected to open up the product details screen with the id 678. Ensure to replace web-domain with the configured domain.
adb shell ‘am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d “http://<web-domain>/product/678”’ \
<package name>

iOS: Try the below in your terminal. It is expected to open up the product details screen with the id 678 . Ensure to replace web-domain with the configured domain.
xcrun simctl openurl booted https://<web domain>/product/678

That would be all for this article. To fully understand what we have done, visit the project repository here. You should also visit the resources section of this article.

In a later article, we will dive into deferred deep links, how to implement them and the role of third-party software in implementing deferred deep links.

Resources

Deep dive into flutter deeplinking (Google iO 2023)
Deepa — Guide to deep linking(Github)
Setup app links for android
Setup universal links for iOS
Deep linking in flutter(Documentation)

--

--

Google Developer Expert Flutter & Dart | Mobile Engineer | Technical Writer | Speaker