Deep Linking in Flutter with go_router: Universal Links and Android App Links
Set up deep linking in Flutter with go_router, iOS Universal Links and Android App Links: verification files, auth redirects, and testing with adb and simctl.

A user taps a link in an email: https://example.com/orders/42. One of three things happens. The app opens on order 42, which is great. The app opens on the home screen, which is confusing. Or the link opens a browser tab while the app sits installed and ignored, which is the worst of the three.
Deep linking is one of those features that’s mostly configuration. It involves two JSON files on a web server, an entitlement, an intent filter and a router, and if any one of them is wrong, the link fails silently. No error, no log, just a browser tab.
Here’s how I set it up with go_router, and how I check every layer when it doesn’t work.
Custom schemes vs verified links
There are two kinds of deep links:
- Custom schemes (
myapp://orders/42) are easy to set up, but any app can claim the same scheme, and they don’t fall back to a website when the app isn’t installed. - Verified HTTPS links (iOS Universal Links, Android App Links) use your real domain. The OS checks that you own both the domain and the app, opens the app if it’s installed, and opens your website if it isn’t.
For anything user-facing, like emails, SMS and shared links, I use verified HTTPS links. Custom schemes are still handy for OAuth redirects and internal tooling.
A quick word on Firebase Dynamic Links
If you’re following an older tutorial that uses Firebase Dynamic Links, stop. Google deprecated the service and shut it down in August 2025, so those links no longer work. The replacement is the setup in this post: verified links on your own domain, plus your own website as the fallback.
Step 1: Define routes that match your URLs
Design your app routes to mirror your web URLs. Then the same link works on the web, on iOS and on Android:
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'orders/:id',
builder: (context, state) =>
OrderScreen(orderId: state.pathParameters['id']!),
),
],
),
GoRoute(
path: '/login',
builder: (context, state) => LoginScreen(
from: state.uri.queryParameters['from'],
),
),
],
);
Because orders/:id is nested under /, opening a deep link builds a back stack with the home screen underneath. Pressing back goes somewhere sensible instead of closing the app.
Treat path parameters as untrusted input. The order screen should handle “this ID doesn’t exist” and “this order belongs to someone else” just as gracefully as a valid ID.
Step 2: Handle links while logged out
This is the case most apps get wrong. A logged-out user taps a link to an order. They should see the login screen, then land on the order after signing in, not on the home screen.
go_router’s redirect handles this. Remember where the user was going, and send them there after login:
GoRouter(
refreshListenable: authNotifier, // a ChangeNotifier that fires on login/logout
redirect: (context, state) {
final loggedIn = authNotifier.isLoggedIn;
final goingToLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingToLogin) {
final from = Uri.encodeComponent(state.uri.toString());
return '/login?from=$from';
}
if (loggedIn && goingToLogin) {
return state.uri.queryParameters['from'] ?? '/';
}
return null; // no redirect
},
routes: [/* ... */],
);
refreshListenable makes the router re-run redirect when auth state changes, so a successful login sends the user on to their destination automatically. Before you redirect to from, check that it’s a relative path inside your app (it starts with /), so nobody can use your login screen as an open redirect. I go deeper into auth flows in production-ready authentication in Flutter.
Step 3: Prove you own the domain
Both platforms fetch a JSON file from your domain’s /.well-known/ directory to verify the link. The files must be served over HTTPS, with no redirects, and return valid JSON.
iOS: apple-app-site-association (no file extension):
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.app"],
"components": [
{ "/": "/orders/*" },
{ "/": "/invite/*" }
]
}
]
}
}
The app ID is your Team ID followed by your bundle ID. Serve it with Content-Type: application/json. Apple fetches this file through its own CDN, so changes can take a while to show up on devices. Don’t panic if an edit doesn’t take effect instantly.
Android: assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AB:CD:EF:..."]
}
}
]
The classic mistake is the fingerprint. If you use Play App Signing, Google re-signs your app with its key, so the fingerprint you need is the app signing key from the Play Console (under App integrity), not your local upload key. Add your debug key’s fingerprint too if you want links to work in local builds. The array accepts several fingerprints.
Step 4: Configure the apps
Android: add an intent filter with autoVerify to your main activity in AndroidManifest.xml:
<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="example.com" />
</intent-filter>
iOS: in Xcode, add the Associated Domains capability to the Runner target with an entry of applinks:example.com. Add one entry per domain, including www if you use it.
Flutter’s deep linking flag: Flutter has a built-in deep link handler that passes incoming links to your router. It’s controlled by FlutterDeepLinkingEnabled in iOS’s Info.plist and by a flutter_deeplinking_enabled meta-data entry in the Android manifest. In recent Flutter versions it’s on by default, so with go_router alone you usually don’t need to touch it. If you use a separate link-handling package such as app_links for custom processing, set the flag to false. Otherwise Flutter and the package both try to handle the same link. Check the Flutter deep linking docs for the default on your Flutter version.
Step 5: Test each layer
When a link opens the browser instead of the app, test from the bottom up.
Is the file reachable? Fetch it the way the OS will:
curl -i https://example.com/.well-known/assetlinks.json
curl -i https://example.com/.well-known/apple-app-site-association
Look for a 200, no redirects and JSON content. A surprising number of hosting setups redirect /.well-known/ or serve a single-page app’s index.html in its place.
Does Android consider the domain verified?
adb shell pm get-app-links com.example.app
adb shell pm verify-app-links --re-verify com.example.app
The first command shows the verification state per domain. You want verified.
Does the route work? Fire the link straight at the app:
# Android
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://example.com/orders/42"
# iOS Simulator
xcrun simctl openurl booted "https://example.com/orders/42"
Finally, test like a user: paste the link into Notes or Messages on a real device and tap it. On iOS, typing a URL straight into Safari’s address bar doesn’t trigger Universal Links. That’s deliberate, and a common source of “it doesn’t work” reports.
Test all the states, too: app not installed, installed and closed, open in the background, logged out. Links opened from push notifications go through the same router, as I describe in push notifications with FCM.
Takeaways
- Use verified HTTPS links (Universal Links and App Links) for anything user-facing. Firebase Dynamic Links is gone.
- Mirror your web URLs in go_router routes, and nest them so deep links get a sensible back stack.
- Handle logged-out users with
redirectand a validatedfromparameter, and re-run it withrefreshListenable. - Host
apple-app-site-associationandassetlinks.jsonunder/.well-known/over HTTPS with no redirects, using the Play app signing fingerprint. - Debug from the bottom up:
curlthe files, checkpm get-app-links, then fire links withadbandsimctl.
Deep links are a small amount of code and a lot of configuration. Getting each layer right once saves a lot of silent failures later.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).