Si ya hiciste navegación en Jetpack Compose hace un par de años, seguramente escribiste rutas como "detalle/{id}" y luego tenías que sacar el argumento a mano con arguments?.getString("id"). Funciona, pero basta con equivocarte en una letra para que la app se cierre, y el compilador no te avisa.
Desde Navigation 2.8.0 eso cambió. Ahora las rutas son clases de Kotlin marcadas con @Serializable, y los argumentos viajan como propiedades normales. En este tutorial te muestro cómo armar una navegación type-safe con un ejemplo sencillo: un catálogo de productos y su pantalla de detalle.
Antes: rutas en texto
En mi repositorio JetPackComposeNavigation tengo un ejemplo hecho a la manera anterior. Las pantallas se definen en una sealed class con un route de tipo String, y el parámetro se agrega concatenando texto: AppScreens.SecondScreen.route + "/{text}".
Ese estilo todavía funciona y te lo vas a encontrar en muchos proyectos. Lo puedes ver explicado en mi canal de YouTube, donde tengo un video de navegación en Compose. El problema es que todo depende de que escribas bien los textos. Con las rutas type-safe, si te equivocas, el error aparece al compilar y no cuando el usuario toca un botón.
Configura el proyecto
Necesitas dos cosas: la librería de Navigation Compose y el plugin de serialización de Kotlin. Estas son las versiones estables que había en Google Maven y Maven Central al momento de escribir (octubre de 2026):
// build.gradle.kts del módulo app
plugins {
// Usa la misma versión de Kotlin que tiene tu proyecto
kotlin("plugin.serialization") version "2.4.21"
}
dependencies {
implementation("androidx.navigation:navigation-compose:2.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
}
Un detalle que confunde a muchos alumnos: la versión del plugin de serialización tiene que coincidir con la versión de Kotlin de tu proyecto. Si tu proyecto usa otra, cambia ese número.
El plugin es el que genera el código para convertir tus clases en rutas. Si quieres entender qué hace por dentro, la guía oficial de serialización de Kotlin lo explica muy bien.
Paso 1: define tus rutas con @Serializable
Cada pantalla es una clase o un objeto. Si la pantalla no recibe datos, usa un object. Si recibe datos, usa una data class:
import kotlinx.serialization.Serializable
@Serializable
object Catalogo
@Serializable
data class DetalleProducto(val productoId: Int)
Eso es todo. DetalleProducto necesita un Int, y no hay forma de navegar a esa pantalla sin pasárselo.
Para el ejemplo uso una lista fija de productos. En una app real vendrían de Room o de una API:
data class Producto(val id: Int, val nombre: String)
val productos = listOf(
Producto(1, "Café molido"),
Producto(2, "Gallo pinto congelado"),
Producto(3, "Rosquillas")
)
Paso 2: arma el NavHost
Aquí se conectan las rutas con las pantallas, siguiendo el mismo esquema de la guía oficial de rutas type-safe. Fíjate en composable<T>(): en lugar de un texto, recibe el tipo de la ruta.
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
@Composable
fun AppNavegacion() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Catalogo) {
composable<Catalogo> {
CatalogoScreen(
onProductoClick = { id ->
navController.navigate(DetalleProducto(productoId = id))
}
)
}
composable<DetalleProducto> { backStackEntry ->
val ruta: DetalleProducto = backStackEntry.toRoute()
DetalleScreen(
productoId = ruta.productoId,
onVolver = { navController.popBackStack() }
)
}
}
}
Tres cosas que antes eran texto y ahora son código:
startDestination = Catalogorecibe el objeto directamente.navController.navigate(DetalleProducto(productoId = id))recibe una instancia de la ruta. Si cambias el nombre del parámetro o su tipo, el compilador te marca todos los lugares que debes corregir.backStackEntry.toRoute()te devuelve la ruta ya armada, con elproductoIdcomoInt. Nada degetString, ni de convertir texto a número.
Te recomiendo que las pantallas no reciban el navController. Mejor pásales funciones como onProductoClick y onVolver. Así puedes hacer previews y pruebas sin crear un controlador de navegación. Es la misma idea de elevar el estado: la pantalla avisa qué pasó y otro decide qué hacer.
Paso 3: las pantallas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CatalogoScreen(onProductoClick: (Int) -> Unit) {
LazyColumn {
items(productos, key = { it.id }) { producto ->
ListItem(
headlineContent = { Text(producto.nombre) },
modifier = Modifier.clickable { onProductoClick(producto.id) }
)
}
}
}
@Composable
fun DetalleScreen(productoId: Int, onVolver: () -> Unit) {
val producto = productos.find { it.id == productoId }
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = producto?.nombre ?: "Producto no encontrado",
style = MaterialTheme.typography.headlineMedium
)
Button(onClick = onVolver) {
Text("Volver al catálogo")
}
}
}
Para probarlo, en tu MainActivity llama a AppNavegacion() dentro de setContent, con tu tema de siempre.
Leer la ruta desde el ViewModel
Lo normal es que la pantalla de detalle tenga su propio ViewModel que busque el producto en la base de datos. No hace falta pasarle el id a mano: SavedStateHandle también tiene toRoute().
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.navigation.toRoute
class DetalleViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
private val ruta = savedStateHandle.toRoute<DetalleProducto>()
val productoId: Int = ruta.productoId
}
Cuando obtienes este ViewModel con viewModel() dentro de composable<DetalleProducto>, el SavedStateHandle ya trae los argumentos de esa ruta. Si usas Hilt, funciona igual con hiltViewModel().
Errores comunes con la navegación en Jetpack Compose
Estos son los que más veo cuando reviso proyectos de alumnos:
- Olvidar el plugin. Si pones
@Serializabley no aplicaste el plugin, el proyecto compila pero la app falla en tiempo de ejecución porque no encuentra el serializador de la ruta. Revisa el bloqueplugins. - Pasar objetos completos. Puedes poner varios campos en una ruta, pero no pases el
Productoentero. Pasa elidy deja que el ViewModel busque el resto. Así no dependes de que los datos quepan en el estado guardado. - Llamar
navigateal recomponer.navigatedebe ir dentro de un evento, como un clic, no suelto en el cuerpo del composable, porque cada recomposición lo volvería a ejecutar. - Usar
popBackStack()en la pantalla inicial. Si sacas el único destino que queda en la pila, el usuario ve una pantalla en blanco.popBackStack()devuelvefalseen ese caso, así que revisa ese valor o usanavigateUp()para los botones de «atrás».
¿Y Navigation 3?
Google también tiene Navigation 3, una librería nueva y separada pensada para Compose, donde tú manejas la pila de pantallas como una lista. Vale la pena seguirla, pero Navigation Compose 2.x es la que vas a encontrar en la mayoría de proyectos que ya están en producción, y las rutas @Serializable que aprendiste aquí te sirven de base para las dos.
Si este tutorial te sirvió, combínalo con el de StateFlow en Jetpack Compose para que cada pantalla tenga su ViewModel bien armado. Y si te sale algún error al migrar tus rutas de texto, déjamelo en los comentarios y lo revisamos.



