NavigationStack en SwiftUI: Guía Completa de Navegación Programática y Deep Linking en iOS 26

Domina NavigationStack en SwiftUI con enrutamiento tipado por enum, el patrón Router con @Observable, deep linking, restauración de estado con SceneStorage y pruebas con Swift Testing. Código listo para Xcode 26 e iOS 26.

NavigationStack SwiftUI iOS 26: Guía Completa

La navegación es, sin exagerar, uno de los aspectos más críticos —y, seamos honestos, más problemáticos— en cualquier aplicación iOS. Con la llegada de NavigationStack en iOS 16 y su consolidación total en iOS 26, Apple por fin nos ha dado una API verdaderamente basada en datos: permite controlar la pila de navegación de forma programática, reactiva y (lo mejor) testeable. Aun así, muchos desarrolladores siguen atrapados en patrones heredados de NavigationView —que ya está deprecada— o en soluciones con booleanos que se vuelven inmanejables en cuanto la app crece un poco.

En esta guía, actualizada a 2026, vas a aprender a dominar NavigationStack desde los fundamentos hasta patrones de producción reales: enrutamiento tipado con enums, el patrón Router/Coordinator, deep linking con URLs universales, restauración de estado con SceneStorage y pruebas de navegación con previews. Todo el código está listo para usar en Swift 6.2 y Xcode 26. Así que, venga, vamos al lío.

Por qué NavigationStack reemplaza a NavigationView

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.

Sobre el Autor Tomasz Wojcik

Tomasz is a Krakow-based iOS engineer with 11 years of Swift experience. He spent four years at Revolut on the Wealth team, where he rewrote the trading charts in SwiftUI and shaved 40% off cold-start time by lazy-loading the analytics SDK. Before Revolut he was at Allegro, Poland's largest e-commerce platform, on the Seller Center iOS team. His specialty is iOS performance work: Instruments deep-dives, memory-graph debugging, and figuring out why your scroll view drops frames only on iPhone SE 2nd-gen. He has contributed patches to swift-syntax and writes a quarterly newsletter for iOS engineers that covers under-discussed APIs like BackgroundTasks and NSFileCoordinator. Tomasz holds the iOS App Development with Swift certification from Apple and occasionally runs paid workshops on Swift concurrency for in-house engineering teams in Europe.