
During my first real production project using Compose Multiplatform, I hit a point where I got stuck. Until last year, there wasn’t a good solution for handling deep links in Compose Multiplatform using the official JetBrains Navigation library. But with the latest update, they’ve finally introduced a straightforward way to manage deep links.
In this blog post, we’ll take a look at how to handle deep links on both Android and iOS using this straightforward approach. 🤓📚
This implementation requires the following library versions or newer:
[versions]
agp = "8.9.1" #Gradle Version
compose-plugin = "1.8.0-beta01"
navigationCompose = "2.9.0-beta02"
[libraries]
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
[plugins]
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
The Navigation Compose library should be added to the commonMain dependencies.
In this section, we’re going to focus on setting up deep links for the Android system.
First, open your app’s AndroidManifest.xml file and add a new intent-filter. This lets the operating system know that your app should handle the deep link when a user clicks it.
<?xml version="1.0" encoding="utf-8"?>
<manifest ...
<application ...>
<activity
...
android:exported="true"
android:launchMode="singleTask">
...
<intent-filter android:autoVerify="true">
<data android:scheme="https" />
<data android:host="your-host.com" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.VIEW" />
</intent-filter>
</activity>
</application>
</manifest>
You’ll need to set your domain scheme, host, and set autoVerify to true. Below, we’ll take a closer look at why enabling autoVerify is important.
It’s also important to set exported="true" to allow other apps to launch your app via deep links. Additionally, use launchMode="singleTask" to prevent duplicate instances of your activity, which can lead to unexpected behavior or memory leaks.
If you need extra help, you can take a look at the official Android documentation.
Now let’s take a look at the necessary setup to handle deep links on iOS.
In Xcode, open your project and select your target app. Then go to the Signing & Capabilities tab, click the + Capability button, and search for Associated Domains in the popup. Once you find it, click to add it.
After that, you’ll see a new section called Associated Domains under Signing & Capabilities. Make sure the All filter is selected so you can see all sections.
To add a Universal Link, click the + button and insert your domain in the following format: applinks:your-host.com
If you have any questions or need more details, you can refer to the official iOS documentation.
Both Android and iOS require you to verify your domains. This is a requirement from Google and Apple to ensure that your app is trusted and authorized by the website to automatically open links for that domain.
Verifying deep links isn’t difficult — the documentation for both Android and iOS is clear, and the steps are straightforward.
If you just want to test deep links on Android without waiting for the backend team, you can manually add the domain in your app settings. Go to Settings > Apps > [Your App] > Open by default, and add your domain there.
For iOS, however, deep link verification is mandatory — even in development. Without it, you won’t be able to test deep links at all.
Now let’s get to the fun part — coding!
We need to create a helper class to handle deep links. This class will be responsible for capturing the deep link and passing it to our NavController to be processed. We'll take a closer look at how this integration works later on.
class DeepLinkHelper(
dispatcher: CoroutineDispatcher,
) {
private val scope = CoroutineScope(dispatcher)
private val _navigationEvents = MutableSharedFlow<String>(replay = 1)
val navigationEvents: Flow<String> = _navigationEvents.asSharedFlow()
fun handle(url: String) {
scope.launch {
_navigationEvents.emit(url)
}
}
}
Now it’s time to set up Koin so we can use this helper in our project.
First, let’s create an object that will be responsible for initializing Koin in both the Android and iOS projects.
val helpersModule = module {
single<DeepLinkHelper> { DeepLinkHelper(dispatcher = Dispatchers.Main) }
}
object InitDI {
fun doInit(config: KoinAppDeclaration? = null) {
startKoin {
config?.invoke(this)
modules(
helpersModule,
)
}
}
}
class DIHelper: KoinComponent {
fun getDeepLinkHelper(): DeepLinkHelper = get()
}
At this point, we just need to initialize Koin for Android in the androidMain folder and for iOS in the iosApp folder.
Android:
class HandlingDeepLinkApp: Application() {
override fun onCreate() {
super.onCreate()
InitDI.doInit {
androidContext(this@HandlingDeepLinkApp)
}
}
}Note:Don’t forget to add this class name to your AndroidManifest.xml as the entry point of your application.
iOS:
import SwiftUI
import ComposeApp
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
InitDI.shared.doInit()
return true
}
}
@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
In this example, we’ll have two screens: a Home Screen and a Settings Screen, which will receive an email as a parameter. We’ll be using type-safe navigation to keep things clean and reliable.
To get started, let’s create a route data class that defines our two screens.
sealed class Route {
@Serializable
data object HomeScreen: Route()
@Serializable
data class SettingsScreen(
@SerialName(MAIL_ARG_NAME) val mail: String,
): Route()
companion object {
const val MAIL_ARG_NAME = "userMail"
}
}
The const MAIL_ARG_NAME represents the parameter name you want to retrieve from the deep link.
This is useful when used as a @SerialName because sometimes parameter names can be a bit awkward or might change in the future. By using a constant, you can simply update the value in one place instead of changing it throughout your code.
Now let’s define our NavHost composable, which will be responsible for handling navigation in this example.
To keep things simple, I’ll place this code inside the starter component of our app.
@Composable
@Preview
fun App() {
val navController = rememberNavController()
HandlingDeepLinkTheme {
KoinContext {
Scaffold(
modifier = Modifier.fillMaxSize(),
) {
NavHost(
navController = navController,
startDestination = Route.HomeScreen,
) {
composable<Route.HomeScreen> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Home Screen")
}
}
composable<Route.SettingsScreen> {
}
}
}
}
}
}
At this point, we’re going to implement the necessary code to make our SettingsScreen handle the deep link that redirects the user to it. In this example, the screen will display the email passed through the deep link.
composable<Route.SettingsScreen>(
deepLinks = listOf(
navDeepLink {
uriPattern = "https://your-host.com/settings?userMail={${Route.MAIL_ARG_NAME}}"
},
),
) {
val args = it.toRoute<Route.SettingsScreen>()
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Settings Screen")
Text("User mail: ${args.mail}")
}
}
The deep link setup above will work if the Android app is not already running. However, if the app is already running when the deep link is triggered, a new intent is received, and we need to handle this scenario manually.
To do this, navigate to your androidMain folder and open MainActivity.kt. In this file, we’ll use the DeepLinkHelper class to properly handle deep links received through a new intent while the app is active.
class MainActivity : ComponentActivity() {
private val deepLinkHelper: DeepLinkHelper by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
intent.dataString?.let { deepLinkHelper.handle(it) }
}
}
view raw
Now, back in our common code (commonMain) — this next step isn’t mandatory, but it helps improve readability and makes the logic reusable if needed.
We’re going to create a helper function that observes and collects a Flow, returning its value to be processed.
@Composable
fun <T> ObserveAsEvents(
flow: Flow<T>,
key1: Any? = null,
key2: Any? = null,
onEvent: (T) -> Unit,
) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(flow, lifecycleOwner.lifecycle, key1, key2) {
flow.collect(onEvent)
}
}
Now we’ve reached the final step to enable deep link handling in the common code.
All you need to do is inject the DeepLinkHelper class into your App.kt file. This allows you to collect the Flow that emits the URI, which is passed from both the Android and iOS apps when a deep link is triggered.
@Composable
@Preview
fun App(
deepLinkHelper: DeepLinkHelper = koinInject(),
) {
val navController = rememberNavController()
ObserveAsEvents(flow = deepLinkHelper.navigationEvents) { uri ->
navController.handleDeepLink(
request = NavDeepLinkRequest.Builder.fromUri(uri = NavUri(uriString = uri)).build()
)
}
...
At this point, you can test your deep link in the Android app, and everything should work as expected. To make testing easier, you can use the App Links Assistant tool available in Android Studio.
If something doesn’t work, double-check the Verify Deep Links section above to make sure everything is set up correctly.
But we’re not done yet — there’s still a bit of Swift code needed to get deep links working properly on the iOS side. So let’s move on to the final part of this post and complete the setup. 💪
Now, open your project in Xcode and navigate to the iOSApp.swift file.
In this file, we need to get an instance of our DeepLinkHelper class. To do that, we'll use the DIHelper we created earlier in the Dependency Injection section.
Then, we’ll observe whether the app was opened via a deep link. If it was, we’ll capture the link and pass the URI to our shared (common) code so it can process the deep link correctly.
...
@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
DIHelper().getDeepLinkHelper().handle(url: url.absoluteString)
}
}
}
}
After this code you are ready to test your deep link on iOS. To do this, first build and run your app in the simulator. Then, close the app (but keep the simulator running) and run the following command in your terminal:
xcrun simctl openurl booted "https://your-host.com/settings?userMail=test@test.com"
Everything should work as expected. If it doesn’t, double-check the Verify Deep Links section above to make sure everything is set up correctly.
I hope you enjoyed this post and found it helpful for your development journey.
If you’d like to see the full example code, you can check out the complete project in this repository — everything you need is there.