Structuring universal links in iOS
A practical way to parse and route universal links using enums and type-safe navigation.
29 Sep 2025 · 6 min read
Universal links are a powerful way to create experiences between a website and an iOS app. As an app grows and supports more types of universal links, managing them can become complex without proper structure.
In this article, we'll look at a simple architecture that keeps universal link handling clean, testable, and easy to extend. Let's dive in.

Handling a universal link usually means parsing the incoming URL, extracting parameters and routing to the right part of the app. To represent a universal link, we can use an enum which acts as the source of truth for every universal link our app understands:
enum UniversalLink {/// Example: https://www.example.com/mobile-app/recipes/// Switches to the recipes tab.case recipes/// Example: https://www.example.com/mobile-app/recipe?id=64ba8b48a92d44eb4e0ef89e/// Opens a specific recipe.case recipe(id: String)/// Example: https://www.example.com/mobile-app/profile?to=settings/// Opens a detail view inside the profile tab.case profile(ProfileUniversalLinkDestination)}enum ProfileUniversalLinkDestination: String {case settingscase favorites}
This keeps the list of supported links visible in one place, enforces type safety, and makes it clear which parameters belong to which link.
For links with more complex payloads we can go beyond simple strings or enums. For example:
struct RecipeUniversalLinkPayload: Equatable {let id: Stringlet referrer: String?}enum RecipeUniversalLinkPayload {case recipe(RecipeUniversalLinkPayload)}
If we need to support many related links (for example, several different links inside a recipes domain), we can also group them:
enum UniversalLink {case recipes(RecipesUniversalLink)}enum RecipesUniversalLink {case listcase detail(id: String)}
Centralize parsing
To centralize all URL parsing logic in one place, we can use a failable or throwing initializer:
extension UniversalLink {init?(from url: URL) {guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }guard let path = components.path.split(separator: "/").map(String.init).last else { return nil }switch path {case "recipes":self = .recipescase "recipe":if let id = components.queryItems?.first(where: { $0.name == "id" })?.value {self = .recipe(id: id)} else {return nil}case "profile":...default:return nil}}}
Instead of just returning nil, we could alternatively throw a concrete error to be able to show or log a specific error description.
Integrating with routing
Once we have a parsed UniversalLink, we need to integrate it with our app's navigation system. If you're using a router-based architecture (as described in this article on using NavigationPath with TabView), the integration becomes straightforward:
.onOpenURL { url inif let link = UniversalLink(from: url) {switch link {case .recipes:appRouter.navigateTo(tab: .recipes)case .recipe(let id):appRouter.navigateTo(tab: .recipes)appRouter.recipesRouter.navigateTo(route: .detail(id: id))case .profile(let destination):appRouter.navigateTo(tab: .profile)appRouter.profileRouter.navigateTo(destination)}}}
Testing universal links
With this approach, we can ensure quality with unit testing which becomes straightforward. We can test each link type independently:
func testRecipeUniversalLink() {let url = URL(string: "https://www.example.com/mobile-app/recipe?id=123")!#expect(UniversalLink(from: url) == .recipe(id: "123"))}
We can write similar tests for each link type to guarantee that URLs are interpreted correctly.



Newsletter
Like to support my work?
Say hi
Related tags
Articles with related topics
Latest articles and tips



