---
title: "Navegación en SwiftUI sin file-based routing: NavigationStack, un enum de rutas y deep links"
description: "Cómo migrar mentalmente de Expo Router a SwiftUI: NavigationStack con un enum Route, el patrón Coordinator, deep links y Universal Links, y qué aporta swift-navigation cuando sheets y alerts se vuelven estado."
author: Ramón Chancay
date: 2026-09-05
lang: es
tags: [SwiftUI, Navegación, NavigationStack, Expo Router, Deep links, Universal Links]
canonical: https://www.ramonchancay.me/es/blog/navegacion-en-swiftui-sin-file-based-routing
---

# Navegación en SwiftUI sin file-based routing: NavigationStack, un enum de rutas y deep links

Expo Router resolvió la navegación en React Native con una idea prestada de la web: la estructura de carpetas es la estructura de rutas, cada archivo es una pantalla y un deep link es una URL que ya coincide con un archivo. En SwiftUI no hay carpetas que definan nada. La navegación es estado: una pila de valores que describe qué pantallas hay abiertas, y las vistas se derivan de esa pila. Este post explica cómo trasladar el modelo mental de Expo Router a `NavigationStack` con un enum de rutas, cuándo un Coordinator ayuda y cuándo estorba, cómo entran los deep links y los Universal Links en ese modelo, y qué agrega swift-navigation cuando sheets, alerts y pushes se vuelven parte del mismo estado.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> Resumen para perezosos <svg class="rc-tldr-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="rc-tldr-body">
<ul>
<li>En SwiftUI la navegación es un array de valores (<code>NavigationPath</code> o <code>[Route]</code>) que tú controlas. Un <code>enum Route</code> con datos asociados reemplaza a la carpeta <code>app/</code> de Expo Router, y <code>navigationDestination(for:)</code> reemplaza a los archivos.</li>
<li>Un deep link es una función pura <code>URL -> [Route]</code>. Como la pila es estado, abrir un enlace es asignar ese array; no hay que "navegar" paso a paso.</li>
<li>Sheets, alerts y confirmaciones también son estado (un opcional que, cuando no es <code>nil</code>, presenta). swift-navigation lo formaliza con un enum de destinos por pantalla para que una vista no pueda mostrar dos cosas a la vez.</li>
</ul>
</div>
</details>

**En este artículo:**

- **El modelo** — [De carpetas a estado](#de-carpetas-a-estado-qué-cambia-respecto-a-expo-router) · [NavigationStack y el enum de rutas](#navigationstack-y-el-enum-de-rutas)
- **La estructura** — [Tabs y pilas](#tabs-cada-una-con-su-pila) · [Coordinator: cuándo sí](#el-patrón-coordinator-cuándo-ayuda-y-cuándo-estorba)
- **Los bordes** — [Deep links y Universal Links](#deep-links-y-universal-links-una-url-que-se-convierte-en-una-pila) · [Sheets y alerts con swift-navigation](#sheets-alerts-y-swift-navigation-cuando-todo-es-estado)

## De carpetas a estado: qué cambia respecto a Expo Router

En Expo Router, `app/orders/[id].tsx` existe y por lo tanto `/orders/42` existe. Para ir ahí escribes `router.push("/orders/42")`, el string se resuelve contra el sistema de archivos, y el stack lo maneja React Navigation por debajo. El estado de navegación es una consecuencia de las URLs que fuiste empujando.

En SwiftUI el orden es inverso. Primero existe el estado (una pila de valores), y las pantallas son una proyección de ese estado. No hay un router al que le pides "ve a tal sitio"; hay un array al que le agregas un valor, y SwiftUI muestra la pantalla que corresponde a ese valor. Las consecuencias:

- **Las rutas son tipos, no strings.** Un `enum Route` con `case order(id: Order.ID)` no admite `/orders/abc` si el id es numérico, y el compilador avisa cuando agregas una pantalla y olvidas su destino.
- **La pila se puede leer, escribir, guardar y restaurar.** Volver al inicio es `path.removeAll()`. Abrir un deep link es `path = [.orders, .order(id: 42)]`. Restaurar la sesión anterior es decodificar el array que guardaste.
- **No hay archivo por pantalla.** Puedes organizar las vistas como quieras; la navegación no depende de dónde están.

```text
Expo Router                              SwiftUI

app/                                     enum Route: Hashable {
├── (tabs)/                                case orders
│   ├── orders/                            case order(id: Order.ID)
│   │   ├── index.tsx                      case orderItem(orderId: Order.ID, sku: String)
│   │   └── [id].tsx                       case settings
│   └── settings.tsx                     }
└── orders/[id]/items/[sku].tsx
                                         @State var path: [Route] = []
router.push("/orders/42")                path.append(.order(id: 42))
router.replace("/")                      path.removeAll()
useLocalSearchParams()                   los datos asociados del caso
```

Lo que se pierde es la ergonomía de la web: no hay URL en la barra, no hay `Link href`, no hay convención que cualquiera reconozca al abrir el proyecto. Lo que se gana es que la navegación sea tan testeable y tan tipada como el resto del estado.

## NavigationStack y el enum de rutas

La pieza central es `NavigationStack` con un `path` vinculado y `navigationDestination(for:)` para mapear cada tipo de valor a su vista. Con un enum de rutas queda así:

```swift
enum Route: Hashable {
    case order(id: Order.ID)
    case orderItem(orderId: Order.ID, sku: String)
    case settings
}

struct OrdersFlow: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            OrdersScreen(onSelect: { order in
                path.append(.order(id: order.id))
            })
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .order(let id):
                    OrderDetailScreen(orderId: id) { sku in
                        path.append(.orderItem(orderId: id, sku: sku))
                    }
                case .orderItem(let orderId, let sku):
                    OrderItemScreen(orderId: orderId, sku: sku)
                case .settings:
                    SettingsScreen()
                }
            }
        }
    }
}
```

Tres detalles que importan:

**El destino se declara una vez, cerca de la raíz del stack.** SwiftUI acepta `navigationDestination(for:)` en cualquier vista dentro de la jerarquía del `NavigationStack` (el ejemplo de Apple lo pone sobre una `List`); lo que no acepta es dentro de un contenedor perezoso como el contenido de una `List` o un `LazyVStack`, donde puede ignorarse con un warning. Centralizar el `switch` sobre `Route` en un solo sitio es una decisión de diseño, no una restricción del framework, y la tomo porque deja el mapa completo de pantallas en un solo archivo. Si vienes de React Navigation, es el `Stack.Navigator` con todas sus `Stack.Screen` declaradas arriba, no en cada componente.

**Las pantallas no empujan rutas; reciben closures.** `OrdersScreen` no sabe que existe `Route`; recibe `onSelect` y avisa. Esto mantiene la pantalla reutilizable en otro flujo y testeable sin navegación. El que sabe de rutas es el flujo, no la vista.

**`NavigationLink(value:)` es la alternativa declarativa** al `append` manual: `NavigationLink(value: Route.order(id: order.id)) { OrderRow(order: order) }` empuja el valor al tocarlo. Es cómodo en listas y funciona con el mismo `navigationDestination`. Uso `append` cuando la navegación es consecuencia de una acción (guardar y pasar a la siguiente pantalla) y `NavigationLink` cuando es una fila que se toca.

Sobre `NavigationPath` frente a `[Route]`: `NavigationPath` es un contenedor con borrado de tipo que acepta valores de cualquier tipo `Hashable`, útil cuando distintas partes de la app empujan tipos distintos. Con un solo enum de rutas, `[Route]` es más simple, se puede inspeccionar en un test (`XCTAssertEqual(path, [.order(id: 42)])`) y se codifica con `Codable` sin `CodableRepresentation`. Prefiero el array.

## Tabs, cada una con su pila

En Expo Router, `(tabs)/` con carpetas anidadas te daba un stack por tab automáticamente. En SwiftUI, cada tab es un `NavigationStack` propio con su propio `path`, y el estado de qué tab está seleccionada es otro valor más:

```swift
enum Tab: Hashable { case orders, profile }

@Observable
final class AppNavigation {
    var tab: Tab = .orders
    var ordersPath: [Route] = []
    var profilePath: [Route] = []
}

struct RootView: View {
    @State private var nav = AppNavigation()

    var body: some View {
        @Bindable var nav = nav
        TabView(selection: $nav.tab) {
            Tab("Pedidos", systemImage: "list.bullet", value: .orders) {
                OrdersFlow(path: $nav.ordersPath)
            }
            Tab("Perfil", systemImage: "person", value: .profile) {
                ProfileFlow(path: $nav.profilePath)
            }
        }
        .environment(nav)
    }
}
```

Tener el objeto de navegación en el entorno permite que cualquier pantalla, o el manejador de deep links, cambie de tab y empuje rutas sin conocer la jerarquía de vistas. Es lo más cercano a `router.push` global que vas a tener, y con la diferencia de que sigue siendo estado observable.

Un detalle de comportamiento: tocar la tab activa en iOS vuelve a la raíz de esa pila, y con `TabView` y `NavigationStack` estándar ese comportamiento lo pone el sistema. No intentes reimplementarlo con `.onChange(of: nav.tab)`: una reselección no cambia el valor del binding, así que no hay cambio que observar. Si necesitas interceptarla (por ejemplo, para hacer scroll al inicio además de vaciar la pila), un `Binding` propio sobre `tab` cuyo `set` compara el valor nuevo con el actual es el punto donde detectarla.

## El patrón Coordinator: cuándo ayuda y cuándo estorba

El Coordinator viene de UIKit: un objeto por flujo que decide qué pantalla sigue, para que los `ViewController` no se conozcan entre sí. En SwiftUI, con el `path` como estado, el objeto `AppNavigation` de arriba ya es un Coordinator en la práctica. La pregunta es cuánta lógica le pones.

Cuándo ayuda:

- **Flujos con ramas que dependen del dominio.** Un onboarding que salta pasos según el tipo de usuario, un checkout que pide verificación solo si el monto supera un umbral. Esa decisión no debería vivir en la vista; vive en un método del coordinator: `func proceedFromCart()` que consulta el estado y hace el `append` correcto.
- **Navegación disparada desde fuera de la UI.** Una notificación push, un deep link, un cambio de sesión que expulsa al usuario. Todos necesitan un punto único que sepa mutar la pila.
- **Tests de flujo.** "Después de pagar con éxito, la pila debe ser `[.confirmation]`" es un test de una función del coordinator, sin UI.

Cuándo estorba:

- **Cuando cada `append` pasa por él.** Si `OrdersScreen` tiene que llamar a `coordinator.showOrder(id:)` para una fila que podría ser un `NavigationLink(value:)`, agregaste indirección sin decisión. El coordinator entra cuando hay lógica, no para todos los pushes.
- **Cuando se vuelve un singleton que las vistas importan.** Se inyecta por entorno o se pasan closures; si una vista de un package de features importa el coordinator de la app, el package ya no es reutilizable.

Mi forma: un objeto `@Observable` por flujo grande (una por tab suele bastar), con las pilas como propiedades y métodos solo para las transiciones que tienen lógica. Todo lo demás son closures o `NavigationLink`.

## Deep links y Universal Links: una URL que se convierte en una pila

En Expo Router no escribías el mapeo de URL a pantalla, porque la URL ya era la ruta, y Expo te abstraía gran parte de la configuración nativa (el esquema y los dominios asociados salían de `app.json`). En SwiftUI tienes que escribir las dos cosas, y lo bueno de la primera es que es una función pura: recibe una `URL` y devuelve la pila que la representa.

```swift
enum DeepLink {
    /// Traduce una URL a un destino: tab más pila. Sin efectos.
    static func parse(_ url: URL) -> (tab: Tab, path: [Route])? {
        switch segments(of: url) {
        case ["orders"]:
            return (.orders, [])
        case ["orders", let id]:
            guard let orderId = Order.ID(id) else { return nil }
            return (.orders, [.order(id: orderId)])
        case ["orders", let id, "items", let sku]:
            guard let orderId = Order.ID(id) else { return nil }
            return (.orders, [.order(id: orderId), .orderItem(orderId: orderId, sku: sku)])
        case ["settings"]:
            return (.profile, [.settings])
        default:
            return nil
        }
    }

    /// Normaliza las dos formas de URL que llegan a la app.
    /// `https://miapp.com/orders/42` tiene host `miapp.com` y path `/orders/42`;
    /// `miapp://orders/42` tiene host `orders` y path `/42`. Con un esquema propio,
    /// el host es el primer segmento de la ruta.
    private static func segments(of url: URL) -> [String] {
        let path = url.pathComponents.filter { $0 != "/" }
        guard url.scheme == "miapp", let host = url.host() else { return path }
        return [host] + path
    }
}
```

Y en la raíz, `.onOpenURL` recibe la URL, sea de un esquema propio (`miapp://orders/42`) o de un Universal Link (`https://miapp.com/orders/42`), y asigna el estado:

```swift
.onOpenURL { url in
    guard let target = DeepLink.parse(url) else { return }
    nav.tab = target.tab
    switch target.tab {
    case .orders: nav.ordersPath = target.path
    case .profile: nav.profilePath = target.path
    }
}
```

Observa que la pila completa se asigna de una vez. En React Navigation, un deep link a una pantalla profunda requería configurar `initialRouteName` para que el botón de atrás tuviera a dónde volver. Aquí la pila `[.order(id: 42), .orderItem(...)]` ya incluye las pantallas intermedias, y el botón de atrás funciona porque el estado es correcto, no porque alguien lo configuró.

Lo que sí hay que configurar fuera del código, y que Expo hacía en `app.json`:

- **Esquema propio:** `CFBundleURLTypes` en `Info.plist`. Funciona en cualquier dispositivo, pero cualquier app puede registrar el mismo esquema.
- **Universal Links:** el entitlement `Associated Domains` con `applinks:miapp.com`, y un archivo `apple-app-site-association` servido por HTTPS en `https://miapp.com/.well-known/` con tu Team ID y bundle ID. Sin ese archivo, el sistema abre Safari en vez de la app. Es el paso que más veces falla en producción, casi siempre por el archivo (JSON inválido, `Content-Type` incorrecto, o el CDN cacheando una versión vieja).

La función `parse` se testea sin dispositivo: una tabla de URLs y pilas esperadas. Es el test más rentable de toda la capa de navegación, porque cubre justo la parte que no se ve hasta que un usuario toca un enlace de un correo.

## Sheets, alerts y swift-navigation: cuando todo es estado

Un push es un valor en la pila. Un sheet, en SwiftUI, es un opcional: `.sheet(item: $editingOrder) { order in EditOrderScreen(order: order) }` presenta cuando `editingOrder` no es `nil` y cierra cuando vuelve a serlo. Un alert es lo mismo con `.alert(item:)`. Y ahí aparece el problema: una pantalla con un sheet de edición, un alert de confirmación de borrado y un sheet de compartir tiene tres opcionales, y nada impide que dos sean no-`nil` a la vez, cosa que SwiftUI resuelve mostrando uno y descartando el otro sin avisar.

swift-navigation (Point-Free, funciona sin TCA) resuelve eso con un solo enum de destino por pantalla:

```swift
import SwiftUINavigation

@Observable
final class OrderDetailModel {
    @CasePathable
    enum Destination {
        case edit(Order)
        case confirmDelete
        case share(URL)
    }

    var destination: Destination?

    func deleteTapped() { destination = .confirmDelete }
    func editTapped(_ order: Order) { destination = .edit(order) }
}
```

```swift
.sheet(item: $model.destination.edit) { order in
    EditOrderScreen(order: order)
}
.alert("¿Eliminar pedido?", isPresented: Binding($model.destination.confirmDelete)) {
    Button("Eliminar", role: .destructive) { model.confirmDelete() }
}
.sheet(item: $model.destination.share) { url in
    ShareSheet(url: url)
}
```

Un solo `destination` opcional, un solo caso activo, y la presentación se deriva de él. Los tests verifican `model.destination == .confirmDelete` después de `deleteTapped()`, sin SwiftUI. La librería aporta los bindings derivados por caso (`$model.destination.edit`) que SwiftUI no ofrece de fábrica; el resto es el mismo patrón de "la presentación es estado" aplicado con disciplina.

> El cambio que más me costó no fue el API sino la costumbre: en React Native abría un modal con `setVisible(true)` desde donde fuera. En SwiftUI, cada vez que una vista presenta algo, la pregunta es "¿de qué estado se deriva esto?". Cuando la respuesta es clara, la navegación se puede testear; cuando no, hay un booleano suelto que va a fallar en un caso que no probaste.

## Preguntas frecuentes

### ¿Hay algo como Expo Router para SwiftUI, con rutas desde archivos?

No, y no creo que llegue: SwiftUI no tiene un sistema de archivos accesible en tiempo de compilación que se pueda convertir en rutas. Lo más cercano es una convención propia: un enum `Route` por flujo y una función `URL -> [Route]`. Es más código que Expo Router y, a cambio, es tipado.

### ¿Cómo hago que la pila sobreviva a que el sistema cierre la app?

Guardando el array. `[Route]` con `Route: Codable` se serializa con `JSONEncoder` y se escribe en `UserDefaults` o en un archivo al pasar a segundo plano (`.onChange(of: scenePhase)`), y se restaura al arrancar. `SceneStorage` funciona para valores simples, pero con un enum con datos asociados es más claro codificarlo tú.

### ¿Qué pasa con `NavigationView`? Lo veo en muchos ejemplos.

Está obsoleto desde iOS 16. `NavigationStack` para pilas, `NavigationSplitView` para maestro-detalle en iPad y Mac. Todo lo de este post asume `NavigationStack`; si heredas código con `NavigationView` e `isActive`, migrarlo a `path` es el primer trabajo.

### ¿Cómo navego desde un ViewModel sin importar SwiftUI?

El ViewModel no navega: expone el resultado de una acción (por ejemplo, `didSave: Order?` o un evento) y el flujo o el coordinator decide el push. O bien el ViewModel recibe una closure `onSaved: (Order) -> Void` que el flujo le pasa. En los dos casos, `Route` y `path` no aparecen en el ViewModel.

### ¿Los Universal Links funcionan en desarrollo sin publicar en App Store?

Sí, siempre que el archivo `apple-app-site-association` esté servido por HTTPS en el dominio y que el entitlement esté en el perfil de firma. En desarrollo se puede agregar `?mode=developer` al dominio en el entitlement: con eso el sistema salta el CDN de Apple y lee el archivo directamente de tu dominio, y el dispositivo necesita tener activado Associated Domains Development en los ajustes de desarrollador. Lo que no funciona es escribir o pegar la URL en la barra de Safari: Apple es explícita en que eso no abre la app. Hay que tocar el enlace desde otra superficie (Notas, Mensajes, un correo) para que cuente como enlace universal.

## Conclusión

La navegación en SwiftUI no se declara con carpetas; se modela como estado. Un `enum Route` con datos asociados reemplaza a la estructura de archivos de Expo Router, `NavigationStack(path:)` con un solo `navigationDestination` en la raíz reemplaza al stack implícito, cada tab tiene su propia pila, y un deep link es una función pura de `URL` a `[Route]` que se testea con una tabla. Los sheets y alerts siguen la misma regla: un solo estado de destino por pantalla, que swift-navigation ayuda a mantener exclusivo.

Para empezar: define el enum de rutas de tu flujo principal, mueve toda la navegación a un `path` que puedas imprimir en un test, escribe `parse(url)` antes de configurar los Universal Links y convierte cada `isPresented` suelto en un caso de un enum de destino. El [siguiente post](/es/blog/data-en-swift-lo-que-tanstack-query-hacia-por-ti) entra en la capa que más se extraña al salir de React Native: el caché de red que TanStack Query manejaba por ti, y cómo diseñar el tuyo con `URLSession`, `AsyncStream` y una base de datos local como caché.
