Skip to content
← All writing
September 8, 2026·6 min read

Deep Links That Actually Work: Universal Links and App Links in Flutter

Deep linking has a deceptive difficulty curve. The concept is trivial — a URL opens a specific screen in your app — and a demo works in an afternoon. Then production arrives: links open the browser instead of the app, work on Android but not iOS, work for you but not for the beta testers, or open the app and land blankly on the home screen. Deep linking may be the highest ratio of "looks done" to "actually done" in mobile development. The consolation is that nearly all of it traces to a handful of well-documented causes, and once you know them, reliability is a checklist rather than a mystery. Here's the reliable path.

Two kinds of links, one clear winner

Custom URL schemes (myapp://order/42) are the legacy mechanism. They still have legitimate uses — OAuth redirect callbacks, links inside your own ecosystem where you control both ends — but they fail the main test twice over: if the app isn't installed, the link is simply dead (often with an ugly browser error), and any app can claim any scheme, so nothing stops another app from registering yours. They're unverifiable by design.

Verified HTTPS linksUniversal Links on iOS, App Links on Android — are what you actually want. The link is a normal web URL (https://yourapp.com/order/42): app installed → opens the app at the right screen; not installed → opens your website, which can show the content and offer the install. One URL works in emails, chats, QR codes, push payloads, and social posts, degrades gracefully everywhere, and ownership is verified — the OS checks a file on your domain proving the site and app belong together, so no other app can intercept your links.

That verification file is where most production failures live, so it deserves the detail.

The server side: where deep links break

iOS looks for https://yourapp.com/.well-known/apple-app-site-association — a JSON file listing your app's team ID + bundle ID and which URL paths it handles. The gotchas are famous enough to have their own folklore: the file must be served over HTTPS without any redirects, with a JSON content type, and no .json extension on the filename. And Apple's CDN fetches it — devices receive it via Apple's infrastructure, typically checking around install time — so a broken file isn't fixed the moment you fix the server; propagation takes time you can't rush. That turns careless iteration into painfully slow iteration, which is why you get this right before shipping, not by trial and error against the store build.

Android looks for https://yourapp.com/.well-known/assetlinks.json, containing your package name and the SHA-256 fingerprint of your signing certificate. Here's the classic production failure, the one that has burned thousands of teams: you list your local keystore's fingerprint, everything verifies in development — but Play App Signing re-signs your release with Google's key, so verification silently fails only for store builds. Links work for you, fail for users, and nothing logs why. The fingerprint you need is in Play Console under Release → Setup → App signing. With android:autoVerify="true" in your manifest's intent filter, verified links open your app directly with no chooser dialog.

Both files accept multiple app entries, so debug and production builds can coexist during development. And validate with the real tools instead of guessing from behavior: Apple provides an AASA validator, and adb shell pm get-app-links <package> shows Android's verification state per domain. Five minutes with the tools beats an evening of "but it works on my phone."

The Flutter side: one stream, one router

In Flutter, the app_links package surfaces incoming links as a stream plus an initial-link getter, and if you use go_router, deep links map naturally onto the route table you already have — a link is just navigation initiated from outside.

The architecture rule that keeps this sane mirrors the one from push notifications: all entry points route through one function. A link arriving while the app is running, while backgrounded, and via cold start are three different code paths on the platform side — funnel them all into a single handleUri(uri) that parses, validates, and navigates, so the three paths can't drift apart as the app evolves. When a deep-link bug report comes in, there's one function to debug.

Cold start is where naive implementations break, because two things are true at once: the link wants to open /order/42 now, and your app hasn't finished initializing — state uninitialized, database opening, user possibly signed out. Two disciplines fix it:

  • Buffer the pending link until initialization completes, then navigate. Racing your own splash screen produces navigation that half-works depending on device speed — the worst kind of bug to reproduce.
  • If the destination needs auth, remember it, run the sign-in flow, then continue to the original destination. A link that dumps users at a login screen and then forgets where they were going is the single most common deep-link UX failure — and to the user it just reads as "the link didn't work," even though technically everything "worked."

Also decide what happens on bad links — the order that was deleted, the malformed or missing ID, the path from an old app version. Route them to a sensible fallback screen with an honest message, never a crash or a blank page. And treat link parameters as untrusted input, because they are: anyone can construct a URL, so /order/42 gets the same authorization check as any other way of reaching order 42. A deep link must never become a way to peek at someone else's data.

Test the matrix, not the happy path

Deep links have a genuine test matrix: platform × app state (cold start / background / foreground) × installed-or-not. It's twelve-ish cases, it fits on an index card, and walking it before release catches what a quick tap-test never does — because the quick test always exercises the same one or two cells.

On Android, adb shell am start -a android.intent.action.VIEW -d "https://yourapp.com/order/42" exercises links repeatably from the command line, which makes it scriptable in CI too. On iOS, send yourself the link in Notes or Messages and tap it — and know one behavior that has convinced countless developers their working setup was broken: typing a universal link into Safari's address bar deliberately does not open the app. Apple treats direct navigation as intent to visit the website. Tap the link from another app and it opens fine. Knowing that quirk saves an afternoon of debugging a system that isn't broken.

One more reality check: link previews in messaging apps fetch your URL before anyone taps it, some corporate email scanners "click" every link, and each platform's browser has its own edge cases. This is why the website fallback isn't optional polish — for some meaningful slice of taps, the web page is the experience.

The takeaway

Deep linking is 20% Flutter code and 80% configuration correctness: an AASA file served exactly to spec, an assetlinks.json carrying the Play App Signing fingerprint rather than your local one, every entry point funneled through one routing function, and pending links buffered through init and auth so they survive a cold start. Do that, walk the test matrix once per release, and links become what they should have been all along — the shortest path from anywhere on the internet to the right screen in your app.

Further reading