On iOS, when tapping an App Link / Universal Link, the system should normally open only the app or the website, not both.
If both open, it means something is misconfigured in how Universal Links are set up.
Here are the most common causes:
✅ 1. The apple-app-site-association (AASA) file is wrong
iOS checks the AASA file hosted on your domain:
https://yourdomain.com/.well-known/apple-app-site-association
If the file is missing, invalid JSON, wrong paths, or has wrong appID, Safari will fail the verification and will open the website instead.
Symptoms:
- App opens briefly, then Safari also opens.
- Or Safari opens first, then the app.
✅ 2. Your app is not handling the link correctly
If the app opens but doesn’t “claim” the URL fast enough, iOS falls back to Safari.
Possible issues:
- Missing
NSUserActivityhandler in Swift. - Missing
scene(_:continue:)in SceneDelegate (iOS 13+). - Misconfigured Associated Domains in Xcode.
✅ 3. The domain isn’t in Associated Domains entitlement
In Xcode you must have something like:
applinks:yourdomain.com
If the domain is missing or has typos (e.g., using https://), iOS won’t activate the link.
✅ 4. The link is redirected
Universal Links break if:
- The link redirects (301/302)
- You use a URL shortener
- Your web server rewrites the path
iOS requires the first request to be the final URL.
If there’s a redirect, iOS opens the webpage instead of the app—and sometimes both.
✅ 5. The user long-pressed and chose “Open in Browser” before
iOS remembers this per domain.
Once chosen, all future links for that domain will open in Safari and the app may still open via app’s own deep link handling.
Fix: user must long-press the link → “Open in App” to reset behavior.
✅ Does app_links handle NSUserActivity and scene(_:continue:)?
No.
The plugin only listens in Dart after iOS has already delivered the Universal Link to the Flutter runtime.
To get to that point, iOS must first:
- Verify your
Associated Domains - Match the universal link
- Route it to your app via:
application(_:continue:restorationHandler:)(AppDelegate, older iOS)scene(_:continue:)(SceneDelegate, iOS 13+)
Flutter apps do not include these handlers by default, and the plugin does not generate them.
Flutter uses a custom AppDelegate, but you usually need to modify the iOS side if you want reliable universal link behavior.
🔧 What the plugin does handle
Once iOS sends the universal link to the Flutter engine, the plugin receives it using:
onAppLinkstreamgetInitialAppLink()
But this happens after the native Swift/Objective-C layer has already successfully processed the link.
🧪 Why this matters for your bug (“iOS opens both app and webpage”)
This usually means iOS is NOT finishing the universal link handoff, often because:
❌ Missing scene(_:continue:)
iOS 13+ requires SceneDelegate.
If it’s missing or broken → Safari also opens.
❌ Misconfigured Associated Domains
Even 1 character wrong breaks universal links.
❌ AASA file wrong or redirecting
iOS fails validation → opens Safari + app.
📌 Want me to check your iOS configuration?
If you send:
- Your
Associated Domainsentry - The domain you use (I can test AASA)
- Your iOS
AppDelegate.swiftandSceneDelegate.swift(or say if you use the new Flutter iOS template without SceneDelegate)
… I can tell you exactly where the problem is.
If you only have AppDelegate.swift
If your iOS project only has AppDelegate.swift and no SceneDelegate.swift, then:
✅ Your Flutter app is using the new iOS template (iOS 13+ without SceneDelegate)
Flutter 3.7+ switched to a simplified iOS project structure where SceneDelegate is removed.
This is fine… BUT:
Universal Links on iOS must still be forwarded through AppDelegate, and some plugins don’t do this for you.
If the app opens and Safari opens, it usually means:
❌ iOS didn’t claim the universal link
so Safari thinks it should open the webpage.
🔍 Here’s what you need to check
1️⃣ Make sure Universal Link methods exist in AppDelegate.swift
Your AppDelegate.swift must include:
override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
return super.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
If this method is missing → iOS ignores the universal link → Safari opens the webpage.
The Flutter template should include it, but sometimes plugins override AppDelegate and break the handler.
2️⃣ Ensure AppDelegate inherits from FlutterAppDelegate
You must have:
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
…
}
If you accidentally changed it to:
UIApplicationDelegate
→ Universal links will not work.
3️⃣ Associated Domains must be added manually
In Xcode → Runner target → Signing & Capabilities:
applinks:yourdomain.com
If missing → Safari opens.
4️⃣ Your apple-app-site-association file must load without redirects
Test:
It must:
- Be JSON
- Have no .json extension
- Load with 200 OK
- Have correct appID
If it redirects (301/302) → Safari opens.
✅ Why you don’t need scene(_:continue:) in Flutter
Flutter’s current iOS template does not use SceneDelegate at all.
- No
SceneDelegate.swift - No
UIApplicationSceneManifestin Info.plist - All lifecycle events are handled by
FlutterAppDelegate
Apple introduced SceneDelegate in iOS 13, but Flutter made the decision to use a single-window app architecture that bypasses it.
This is normal and supported.
✅ So what method does Flutter use for Universal Links?
Flutter uses only one entry point:
application(_:continue:restorationHandler:)
This is the method you added.
If this exists → Universal Links work.
If it’s missing → Safari opens instead.
There is no requirement for:
scene(_:continue:)
…because Flutter does not register any scenes.
🧩 When would you need scene(_:continue:)?
Only if:
- You manually added a
SceneDelegate.swift - OR enabled iOS multi-window support (iPadOS)
- OR modified Info.plist to use Scenes
If you didn’t do any of these → you do NOT need scene lifecycle handlers.
How does this code resolves the issue
Here’s the clear explanation of why that exact code fixes the “app + Safari both open” problem for Universal Links on iOS.
🚦 What iOS expects when a Universal Link is tapped
When the user taps a Universal Link, iOS:
-
Checks your Associated Domains
-
If the link matches → opens your app
-
Then calls:
application(_:continue:restorationHandler:)
This is the ONLY method that confirms your app received the universal link.
If your app does not override this method, something critical happens:
❌ What happens WITHOUT this method
If the method is missing:
-
iOS cannot verify that your app handled the link
-
So iOS assumes:
“The app didn’t claim the link → fallback to Safari”
-
Safari opens your webpage
-
The app also opens later (because Flutter still picks up the link internally)
Result:
❗ The app opens AND Safari opens the webpage
— the exact bug you were seeing.
✅ What happens WITH this method
By overriding this method:
override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
return super.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
… you allow FlutterAppDelegate to run its internal logic:
-
FlutterAppDelegate intercepts the universal link
-
It sends the link to Flutter plugins (
uni_links,app_links, etc.) -
It returns true to iOS (via
super)
Returning true tells iOS:
“Yes, I successfully handled this universal link — DO NOT open Safari.”
And then:
🎉 iOS opens only your app
✓ No Safari
✓ App receives the link
✓ Everything works correctly
🧠 Why returning super.application(...) is important
FlutterAppDelegate (the parent class) contains the real Universal Link handling code for Flutter.
If you didn’t forward the call to super, deep links would break completely.
FlutterAppDelegate
Below is the actual Universal Link handling code from FlutterAppDelegate used by Flutter on iOS.
This is taken from the official Flutter engine source (FlutterAppDelegate.mm).
I’m showing only the relevant parts — the method that handles Universal Links and why returning super is required.
📌 Actual code from FlutterAppDelegate
🔍 1. The Universal Link handler in FlutterAppDelegate
- (BOOL)application:(UIApplication*)application
continueUserActivity:(NSUserActivity*)userActivity
restorationHandler:(void (^)(NSArray* __nullable))restorationHandler {
// This sends the link into the Flutter plugin system
return [self.applicationController application:application
continueUserActivity:userActivity
restorationHandler:restorationHandler];
}
This is the exact method your override forwards to when you call:
return super.application(...)
🔍 2. Inside FlutterApplicationController
This is where the link is actually delivered to Dart.
- (BOOL)application:(UIApplication*)application
continueUserActivity:(NSUserActivity*)userActivity
restorationHandler:(void (^)(NSArray*))restorationHandler {
if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
NSString* url = userActivity.webpageURL.absoluteString;
if (_plugins) {
// Notify all registered plugins about the link.
if ([_plugins application:application
continueUserActivity:userActivity
restorationHandler:restorationHandler]) {
return YES;
}
}
// If no plugin handled it, return NO
return NO;
}
return NO;
}
🧠 What this means:
✔️ This code inspects the clicked link
(NSUserActivityTypeBrowsingWeb)
✅Converts the URL
userActivity.webpageURL.absoluteString;
✔️ Passes the link into Flutter’s plugin system
The plugin (app_links, uni_links, etc.) receives it here.
✔️ Returns YES if handled
This tells iOS:
“My app successfully handled this universal link.”
So Safari must NOT open.
❗ Why your override is essential
Your override:
override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
return super.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
does only one critical thing:
👉 It forwards the call into this Flutter engine code
…which then forwards it to plugins.
Without your override, the method never reaches the Flutter engine → iOS thinks the app didn’t handle the link → Safari opens the webpage.
🎉 Result
After adding the override:
-
Flutter receives the universal link
-
FlutterAppDelegate returns YES
-
iOS does not open Safari
-
Only your app opens
