Antes de iOS 16, NavigationView era la única herramienta disponible y, francamente, tenía varios problemas estructurales serios: no permitía una navegación verdaderamente programática, los NavigationLink con bindings de booleanos se multiplicaban sin control, y el deep linking era un auténtico calvario (lo digo por experiencia propia, tras dos apps de comercio electrónico). Apple deprecó NavigationView a favor de NavigationStack y NavigationSplitView para apps que apunten a iOS 16 en adelante.
Las ventajas clave de NavigationStack son:
- Basado en datos: la pila de navegación se representa como un array o un
NavigationPath, no como estado booleano disperso por toda la app.
- Lazy loading de destinos: las vistas solo se construyen cuando son empujadas, no al declarar los
NavigationLink.
- Deep linking trivial: basta con modificar el path para navegar a cualquier pantalla.
- Restauración de estado:
NavigationPath es Codable, así que puedes persistirlo con SceneStorage sin dramas.
- Testeable: la pila es un valor inspeccionable, no un efecto secundario escondido.
Fundamentos: NavigationStack con path vinculado
El patrón mínimo para navegación programática empieza por vincular la propiedad path del NavigationStack a una variable @State. Este ejemplo usa un array tipado, que es la forma más sencilla cuando solo navegas entre un único tipo de dato:
import SwiftUI
struct ContentView: View {
@State private var path: [Int] = []
var body: some View {
NavigationStack(path: $path) {
List(1...20, id: \.self) { number in
NavigationLink(value: number) {
Text("Elemento \(number)")
}
}
.navigationTitle("Inicio")
.navigationDestination(for: Int.self) { number in
DetailView(number: number, path: $path)
}
}
}
}
struct DetailView: View {
let number: Int
@Binding var path: [Int]
var body: some View {
VStack(spacing: 16) {
Text("Detalle #\(number)")
.font(.largeTitle)
Button("Ir al siguiente") {
path.append(number + 1)
}
Button("Volver al inicio") {
path.removeAll()
}
}
.padding()
}
}
Fíjate bien en las tres operaciones clave sobre el path: append para empujar una vista, removeLast() para volver atrás, y asignar un array vacío para hacer "pop to root". Es manipulación de datos plana, sin más. SwiftUI se encarga de sincronizar la UI automáticamente.
NavigationPath para rutas heterogéneas
Un array tipado solo funciona si todas las pantallas reciben el mismo tipo de dato. Pero, claro, en aplicaciones reales necesitas empujar combinaciones de tipos distintos: un User, un Product.ID, una ruta a ajustes, etc. Para eso existe NavigationPath, un wrapper con borrado de tipos que acepta cualquier valor Hashable y Codable:
struct RootView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
.navigationDestination(for: User.self) { user in
ProfileView(user: user)
}
.navigationDestination(for: SettingsRoute.self) { route in
SettingsContainer(route: route)
}
}
.environment(\.navigate, NavigateAction(path: $path))
}
}
Puedes registrar tantos modificadores .navigationDestination(for:) como necesites. SwiftUI elegirá el correcto según el tipo dinámico del valor empujado.
Enrutamiento tipado con enums: el patrón recomendado para 2026
Trabajar directamente con modelos de dominio en la pila tiene (al menos) dos problemas gordos: acopla la capa de navegación a la capa de modelo, y te obliga a hacer conformancia a Hashable de objetos que quizá no deberían serlo. ¿La solución estándar en 2026? Definir un enum Route que enumera todas las pantallas de una sección:
enum AppRoute: Hashable, Codable {
case productList(category: String)
case productDetail(id: UUID)
case userProfile(userID: UUID)
case settings
case about
case privacyPolicy
}
struct RootView: View {
@State private var path: [AppRoute] = []
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
destination(for: route)
}
}
}
@ViewBuilder
private func destination(for route: AppRoute) -> some View {
switch route {
case .productList(let category):
ProductListView(category: category)
case .productDetail(let id):
ProductDetailView(productID: id)
case .userProfile(let userID):
ProfileView(userID: userID)
case .settings:
SettingsView()
case .about:
AboutView()
case .privacyPolicy:
PrivacyPolicyView()
}
}
}
Las ventajas son enormes: un solo switch exhaustivo que el compilador valida, rutas Codable listas para persistir y deep links, y cero strings mágicos. Si añades una pantalla nueva y olvidas su destino, el compilador te lo recordará sin piedad (y eso es buenísimo).
Centralizando la navegación: el patrón Router con @Observable
En apps medianas o grandes necesitas que cualquier vista pueda iniciar una navegación sin pasar bindings por cada nivel. La solución moderna combina el nuevo macro @Observable de Swift 6 con @Environment para inyectar un Router global:
import SwiftUI
import Observation
@Observable
final class AppRouter {
var path: [AppRoute] = []
func push(_ route: AppRoute) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeAll()
}
func replace(with routes: [AppRoute]) {
path = routes
}
}
@main
struct CraftedApp: App {
@State private var router = AppRouter()
var body: some Scene {
WindowGroup {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
RouteView(route: route)
}
}
.environment(router)
}
}
}
struct ProductCardView: View {
let product: Product
@Environment(AppRouter.self) private var router
var body: some View {
Button {
router.push(.productDetail(id: product.id))
} label: {
ProductCardContent(product: product)
}
}
}
Con esta arquitectura, cualquier vista —por profunda que esté en el árbol— puede invocar router.push(...). Y, ya que estamos, el router es el único sitio donde se toman decisiones de navegación, lo que simplifica muchísimo los tests unitarios.
Cuándo usar Router vs. path local
- Path local (
@State en la vista raíz): flujos aislados como un wizard de onboarding, un checkout o un formulario multietapa.
- Router global (
@Observable en environment): navegación a nivel de app, sobre todo cuando recibes deep links, notificaciones push o URLs desde el sistema.
Deep linking con URLs: del link al destino
Aquí es donde NavigationStack realmente brilla. Con el router expuesto en el environment, manejar un deep link se reduce a parsear la URL y modificar el path. Empecemos por registrar el scheme en Info.plist y después definir el parseador:
struct DeepLinkParser {
static func routes(from url: URL) -> [AppRoute]? {
guard url.scheme == "craftedapp" else { return nil }
let components = url.pathComponents.filter { $0 != "/" }
switch components.first {
case "product":
guard let idString = components[safe: 1],
let id = UUID(uuidString: idString) else {
return nil
}
return [.productDetail(id: id)]
case "category":
guard let name = components[safe: 1] else { return nil }
return [.productList(category: name)]
case "user":
guard let idString = components[safe: 1],
let userID = UUID(uuidString: idString) else {
return nil
}
return [.userProfile(userID: userID)]
case "settings":
return [.settings]
default:
return nil
}
}
}
extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
Y después conectamos el parseador al router con onOpenURL:
@main
struct CraftedApp: App {
@State private var router = AppRouter()
var body: some Scene {
WindowGroup {
RootView()
.environment(router)
.onOpenURL { url in
if let routes = DeepLinkParser.routes(from: url) {
router.replace(with: routes)
}
}
}
}
}
Con esto, abrir craftedapp://product/3FA85F64-5717-4562-B3FC-2C963F66AFA6 empuja la pila directamente al detalle. Ojo, también puedes construir rutas de varios pasos —por ejemplo [.productList(category: "libros"), .productDetail(id: id)]— para que el usuario pueda usar el botón Atrás y volver a la categoría. Ese detalle de UX, para mí, marca la diferencia entre una app y una buena app.
Universal Links en iOS 26
Para Universal Links (HTTPS), el código es prácticamente idéntico: iOS entrega la URL a través de onContinueUserActivity("NSUserActivityTypeBrowsingWeb") o onOpenURL, dependiendo del tipo de escena, y tú mapeas la URL a un array de AppRoute. La clave —y esto no me canso de repetirlo— es mantener la lógica de parsing fuera de las vistas.
Restauración de estado con SceneStorage
Cuando el sistema mata tu app por presión de memoria, el usuario espera volver justo donde estaba. Como NavigationPath es Codable, puedes persistir su contenido en SceneStorage (un almacenamiento ligado a la escena que, además, sobrevive a relanzamientos):
struct RootView: View {
@Environment(AppRouter.self) private var router
@SceneStorage("navigation.path") private var encodedPath: Data?
var body: some View {
@Bindable var router = router
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
RouteView(route: route)
}
}
.task(id: router.path) {
encodedPath = try? JSONEncoder().encode(router.path)
}
.task {
guard let data = encodedPath,
let restored = try? JSONDecoder()
.decode([AppRoute].self, from: data) else { return }
router.path = restored
}
}
}
Al usar un array tipado de AppRoute en lugar de NavigationPath ganas tipado fuerte a la hora de decodificar. Si prefieres NavigationPath, usa su API específica: NavigationPath.CodableRepresentation.
Combinando NavigationStack con tabs
Un error que veo constantemente en revisiones de código: poner un solo NavigationStack en la raíz cuando usas TabView. Lo correcto es un NavigationStack por pestaña, porque cada tab tiene su propio historial independiente. El router, entonces, mantiene un path por pestaña:
@Observable
final class TabbedRouter {
var selectedTab: Tab = .home
var homePath: [AppRoute] = []
var searchPath: [AppRoute] = []
var profilePath: [AppRoute] = []
enum Tab: Hashable {
case home, search, profile
}
func push(_ route: AppRoute, in tab: Tab) {
selectedTab = tab
switch tab {
case .home: homePath.append(route)
case .search: searchPath.append(route)
case .profile: profilePath.append(route)
}
}
}
struct MainTabView: View {
@Environment(TabbedRouter.self) private var router
var body: some View {
@Bindable var router = router
TabView(selection: $router.selectedTab) {
NavigationStack(path: $router.homePath) {
HomeView()
.navigationDestination(for: AppRoute.self, destination: RouteView.init)
}
.tabItem { Label("Inicio", systemImage: "house") }
.tag(TabbedRouter.Tab.home)
NavigationStack(path: $router.searchPath) {
SearchView()
.navigationDestination(for: AppRoute.self, destination: RouteView.init)
}
.tabItem { Label("Buscar", systemImage: "magnifyingglass") }
.tag(TabbedRouter.Tab.search)
NavigationStack(path: $router.profilePath) {
ProfileView()
.navigationDestination(for: AppRoute.self, destination: RouteView.init)
}
.tabItem { Label("Perfil", systemImage: "person") }
.tag(TabbedRouter.Tab.profile)
}
}
}
Un deep link ahora puede cambiar de pestaña y modificar el path correspondiente en una sola operación atómica. Bonito, ¿verdad?
Patrones avanzados: guards de navegación
A veces una pantalla solo debe mostrarse si el usuario está autenticado, ha aceptado términos, o tiene ciertos permisos. Un guard en el router centraliza toda esa lógica en un único sitio:
extension AppRouter {
func pushWithGuard(_ route: AppRoute, isAuthenticated: Bool) {
if route.requiresAuth && !isAuthenticated {
path.append(.login(returnTo: route))
} else {
path.append(route)
}
}
}
extension AppRoute {
var requiresAuth: Bool {
switch self {
case .userProfile, .settings:
return true
default:
return false
}
}
}
El beneficio: la vista que dispara la navegación no necesita saber absolutamente nada sobre autenticación. Delega en el router, y el router ya decide si redirigir a una pantalla de login con el returnTo correspondiente para completar el flujo tras autenticarse.
Pruebas de navegación con Previews y Swift Testing
Esto, honestamente, es una de las cosas que más me gustan del nuevo modelo. La navegación basada en datos es la primera arquitectura de SwiftUI verdaderamente testeable. Puedes escribir pruebas unitarias sobre el router sin tocar la UI:
import Testing
@testable import CraftedApp
@Suite("AppRouter")
struct AppRouterTests {
@Test("push añade la ruta al final del path")
func pushAppendsRoute() {
let router = AppRouter()
router.push(.settings)
#expect(router.path == [.settings])
}
@Test("popToRoot vacía el path")
func popToRootEmptiesPath() {
let router = AppRouter()
router.path = [.settings, .about, .privacyPolicy]
router.popToRoot()
#expect(router.path.isEmpty)
}
@Test("deep link a producto genera la ruta correcta")
func deepLinkParsesProduct() throws {
let id = UUID()
let url = URL(string: "craftedapp://product/\(id.uuidString)")!
let routes = try #require(DeepLinkParser.routes(from: url))
#expect(routes == [.productDetail(id: id)])
}
}
Y para pruebas visuales, los previews de Xcode 26 permiten inyectar un router con un estado preconfigurado, mostrando exactamente la pantalla que quieres inspeccionar:
#Preview("Deep link a settings") {
let router = AppRouter()
router.path = [.settings]
return RootView()
.environment(router)
}
Errores comunes y cómo evitarlos
- Usar
NavigationLink(isActive:) con booleanos: esta API está deprecada. Migra a NavigationLink(value:) con navigationDestination y no mires atrás.
- Anidar
NavigationStack innecesariamente: solo necesitas uno por pestaña o por escena independiente. Anidar rompe la pila (y, en mi experiencia, causa bugs rarísimos que tardas horas en encontrar).
- Hacer
Hashable a tus modelos de dominio: usa un enum Route y convierte los modelos en identificadores (UUID, ID tipado) dentro de la ruta.
- Olvidar que
@Observable requiere @Bindable: cuando necesitas un binding desde un valor del environment ($router.path), añade @Bindable var router = router en el cuerpo de la vista.
- No debouncear deep links durante el lanzamiento: si
onOpenURL se dispara antes de que el router esté listo, usa .task o una cola de pendientes.
Preguntas frecuentes
¿Puedo seguir usando NavigationView en iOS 26?
Técnicamente sí, pero está deprecada desde iOS 16 y Apple ha anunciado que podría eliminarla en futuras versiones. Para apps nuevas, usa siempre NavigationStack (para layouts de una columna) o NavigationSplitView (para layouts de dos o tres columnas tipo iPad o Mac).
¿Cuál es la diferencia entre NavigationPath y un array tipado?
Un array tipado ([AppRoute]) ofrece tipado fuerte, es más fácil de inspeccionar y codificar, y es la opción recomendada cuando tienes un enum Route exhaustivo. NavigationPath es un contenedor con borrado de tipos, útil cuando necesitas mezclar tipos heterogéneos sin un enum central; pero pierdes el tipado estático y requiere más cuidado al serializar.
¿Cómo manejo navegación modal con sheet junto a NavigationStack?
Los sheet son ortogonales al NavigationStack: puedes tener un router que gestione tanto el path como un @Observable que controle sheets y fullScreenCovers. Lo habitual es añadir propiedades como presentedSheet: SheetRoute? al router y vincularlas con .sheet(item:).
¿NavigationStack funciona igual en iPad y macOS?
Sí, aunque para layouts multi-columna en pantallas grandes considera NavigationSplitView, que proporciona una mejor experiencia. Puedes combinar ambos: un NavigationSplitView como contenedor raíz con NavigationStack dentro de la columna de detalle.
¿Cómo pruebo un deep link sin tener que instalar la app?
En el simulador, ejecuta xcrun simctl openurl booted "craftedapp://product/UUID-AQUI" desde la terminal. En Xcode 26 también puedes añadir un esquema de lanzamiento con la URL preconfigurada, o usar previews que inyecten el path directamente en el router para validar la UI resultante.
Conclusión
NavigationStack con un router @Observable y enrutamiento tipado por enum es, en 2026, el estándar de facto para apps SwiftUI en producción. Punto. El patrón te da navegación programática trivial, deep linking robusto, restauración de estado gratis con SceneStorage, y una capa de navegación completamente testeable. El esfuerzo inicial de definir tu enum AppRoute y tu router se amortiza a la primera pantalla nueva que añades: solo tienes que añadir un caso al enum y un caso al switch del destino. Listo.
Si estás migrando una app desde NavigationView, mi consejo es empezar por la escena raíz, introducir el router, y luego ir reemplazando los NavigationLink(isActive:) por NavigationLink(value:) pantalla a pantalla. En un par de iteraciones tendrás una arquitectura de navegación moderna, coherente y (esto es lo importante) preparada para lo que venga en iOS 27.