Hotwire Native

Deep link into apps with Hotwire Native and universal links (iOS)


This post was updated in October 2025 to work with the latest version of Hotwire Native (1.2 and up).

By default all external links in your Hotwire Native app will open in a Safari modal. This is great for most cases, but it’s also very nice to directly open links in a specific app, for example, when linking to a location on Google Maps, or an Instagram post.

This is where universal links come in. They are just standard https links that you can also open in the browser, but if the user has the app installed, the system will open the link directly in the app.

When users tap or click a universal link, the system redirects the link directly to your app without routing through Safari or your website. In addition, because universal links are standard HTTP or HTTPS links, one URL works for both your website and your app. If the user has not installed your app, the system opens the URL in Safari, allowing your website to handle it.

Hotwire Native

Fortunately, Hotwire Native offers some flexibility on how to deal with links through ‘route decision handlers’ (introduced in 1.2.0), and with a little bit of code we can open links that have a the corresponding app installed in their app, and open the rest in a Safari modal.

There are two built-in handlers:

  1. Open links through the system (SystemNavigationRouteDecisionHandler).
  2. Open links in a Safari modal (SafariViewControllerRouteDecisionHandler), the default.

The first option works well with universal links, they open in their app as expected. But if you don’t have the app installed (or there is no app at all), those links will now be opened in the Safari app, no longer in a modal, and the user will have left your app.

Instead of using one of the options above, we can also write our own custom handler and have the best of both worlds. We’ll call it the UniversalRouteDecisionHandler. Put the following in a file named UniversalRouteDecisionHandler.swift.

It’s based on the SafariViewControllerRouteDecisionHandler, especially the code for matches is copied as it is. The ‘magic’ happens in the handle method.

Unfortunately, as of today there is no other way to detect if an app answers to an universal links then trying to open it and see what happens. We tell the system to open the link (but as an universal link only), and if it fails, we fall back by calling Hotwire Native’s built in SafariViewControllerRouteDecisionHandler to handle the link.

import Foundation
import HotwireNative
import UIKit

public final class UniversalRouteDecisionHandler: RouteDecisionHandler {
    public let name: String = "universal-route-decision-handler"

    public init() {}

    public func matches(location: URL,
                        configuration: Navigator.Configuration) -> Bool
    {
        /// SFSafariViewController will crash if we pass along a URL that's not valid.
        guard location.scheme == "http" || location.scheme == "https" else {
            return false
        }

        if #available(iOS 16, *) {
            return configuration.startLocation.host() != location.host()
        }

        return configuration.startLocation.host != location.host
    }

    public func handle(location: URL,
                       configuration: Navigator.Configuration,
                       navigator: Navigator) -> Router.Decision
    {
        UIApplication.shared.open(location, options: [.universalLinksOnly: true]) { success in
            if !success {
                // no app to handle the link, open in a modal through the built-in SafariViewControllerRouteDecisionHandler
                let handler = SafariViewControllerRouteDecisionHandler()

                _ = handler.handle(location: location,
                                   configuration: configuration,
                                   navigator: navigator)
            }
        }

        return .cancel
    }
}

The only thing left is to register our new handler in your AppDelegate. We keep some of the existing stack of handlers as well, as they handle internal links (AppNavigationRouteDecisionHandler) and other links, like email:... or tel:... or other custom URL schemes (SystemNavigationRouteDecisionHandler).

Hotwire.registerRouteDecisionHandlers([
    AppNavigationRouteDecisionHandler(),
    UniversalRouteDecisionHandler(), // this replaces `SafariViewControllerRouteDecisionHandler()`
    SystemNavigationRouteDecisionHandler()
])

This way your users will have the best user experience for all links in your app. If a link has a corresponding app installed, that will be used. If not, the link will open in a modal Safari screen in your app.

Thinking of building a mobile app with Hotwire Native? I can help you create smooth native experiences with your minimal effort in your existing Rails app.

Get in touch