Navigator.push, pushReplacement y go_router: navegación Flutter

- Andrés Cruz - EN In english

La navegación es uno de los temas que más dolores de cabeza causa cuando comienzas con Flutter… y, curiosamente, también uno de los que más fácil vuelve tu vida una vez que lo dominas. En mis primeros proyectos me di cuenta de que casi todo se apoya en lo mismo: gestionar una pila de rutas como si fuera una pila real (push, pop… y algo más de magia).

En esta guía te llevo desde cero hasta lo avanzado: Navigator básico, Navigator 2.0, go_router, rutas anidadas, casos prácticos y buenas prácticas reales que me han salvado proyectos más de una vez.

Podemos comparar la navegación en Flutter con el funcionamiento de la estructura de datos de la pila (stack): cada pantalla que abres se apila encima de la anterior, y para volver simplemente vas retirando elementos del tope.

Continuamos del post anterior, en el que aprendimos a usar el FutureBuilder, async y await en Flutter.

Qué es la navegación en Flutter y cómo funciona la pila de rutas

Cómo Flutter gestiona las pantallas como rutas

En Flutter, cada pantalla es una ruta que se apila sobre otra. Cuando navegas hacia adelante, empujas una nueva ruta al stack; cuando vuelves atrás, la retiras. El widget encargado de gestionar todo esto es la clase Navigator.

Cuando lo entendí así —como una simple pila— todo cobró sentido: "Si empujo 5 pantallas, necesitaré 5 pops para volver". En mis primeras apps (listas de productos, pantallas de detalles, etc.) esta analogía mental me ayudó a evitar errores que, de otro modo, hubieran sido muy difíciles de depurar.

Diferencias entre navegación móvil y Flutter Web

Es importante tener en cuenta que la navegación no se comporta igual en todas las plataformas. En apps móviles:

  • Prima la animación de transición entre pantallas.
  • Las transiciones visuales forman parte de la experiencia del usuario.
  • El historial es interno a la app y el usuario lo controla con el botón "Atrás" del dispositivo.

En Flutter Web, el escenario cambia considerablemente:

  • Las rutas se convierten en URLs reales del navegador.
  • El botón "Atrás" del navegador debe estar correctamente integrado con tu stack de rutas.
  • Necesitas que tu navegación sea declarativa para que la URL siempre refleje el estado actual de la app.

Navigator en Flutter: conceptos esenciales y primeros pasos

Ten en cuenta que las pantallas en Flutter se denominan oficialmente rutas, pero para simplificar usaré indistintamente los términos "pantalla" y "página". Flutter centraliza toda la gestión de navegación en la clase Navigator. En este artículo cubriremos sus cinco métodos principales:

  1. Navigator.push()
  2. Navigator.pop()
  3. Navigator.pushReplacement()
  4. Navigator.pushAndRemoveUntil()
  5. Navigator.popUntil()

Imagina que estás construyendo una aplicación de compras en línea: quieres mostrar los detalles de un artículo en una nueva pantalla cuando el usuario lo selecciona de un listado, y volver al listado cuando presione "Atrás". Para este caso de uso básico bastan los métodos:

  1. Navigator.push()
  2. Navigator.pop()

La clase Navigator proporciona todas las capacidades de navegación dentro de una aplicación Flutter.

Navigator muta la pila de rutas agregando y removiendo páginas mediante los métodos señalados. Navigator.push() navega a una pantalla nueva y Navigator.pop() regresa desde la pantalla actual a la anterior.

El método push() recibe BuildContext como primer argumento y un PageRoute como segundo. La forma más común de construir esa ruta es con MaterialPageRoute.

Navigator.push() y Navigator.pop() explicados de forma práctica

  • Navigator.push() agrega una nueva pantalla al stack de rutas.
    Navigator.pop() retira la pantalla actual y te devuelve a la anterior.

Cuando hice mi primera app con pantallas de detalles lo viví exactamente así: "Presiono un ítem → push; presiono Atrás → pop". Simple, directo y efectivo.

Navigator.push(
  context,
  MaterialPageRoute(builder: (_) => DetalleScreen()),
);

Para volver a la pantalla anterior basta con llamar a Navigator.pop(context). Si además necesitas devolver un resultado a la pantalla anterior, puedes pasarlo como segundo argumento: Navigator.pop(context, resultado). La pantalla que hizo el push puede capturar ese valor con await.

pushReplacement, pushAndRemoveUntil y popUntil en flujos reales

Los tres métodos avanzados del Navigator resuelven situaciones que push y pop no pueden manejar solos:

  • Navigator.pushReplacement(): ideal para el flujo login → home.
    Reemplaza la ruta actual por la nueva, de modo que al presionar "Atrás" el usuario no puede regresar a la pantalla que fue reemplazada. En uno de mis proyectos lo usé para evitar que el usuario volviera a la pantalla de inicio de sesión después de autenticarse… y fue mano de santo.
  • Navigator.pushAndRemoveUntil(): perfecto para cerrar sesión o resetear un flujo completo.
    Empuja una nueva ruta y elimina todas las rutas anteriores del stack hasta que se cumpla una condición (el predicado). En una app con checkout, cuando el pago se completaba, lo usaba para limpiar todo el historial antes de llevar al usuario al resumen del pedido.
  • Navigator.popUntil(): útil cuando quieres regresar a una pantalla específica sin hacer pop uno a uno.
    Yo lo usé para saltar desde la pantalla de confirmación de pago directamente al inicio de un flujo, sin que el usuario viera todas las pantallas intermedias al pulsar "Atrás".

Pasar datos entre pantallas: argumentos, ModalRoute y resultados

Puedes pasar datos a una nueva ruta usando Navigator.pushNamed() con el parámetro arguments:

Navigator.pushNamed(
  context,
  '/detalle',
  arguments: {'id': 25, 'nombre': 'Pepe'},
);

Y los recuperas en la pantalla destino accediendo a ModalRoute:

final args = ModalRoute.of(context)!.settings.arguments;

Es importante validar el tipo antes de usar los argumentos, ya que settings.arguments retorna Object?. Un cast explícito como args as Map<String, dynamic> es la práctica recomendada.

Rutas con nombre: cuándo convienen y cómo organizarlas

Las rutas con nombre (named routes) te permiten navegar usando cadenas de texto en lugar de instanciar clases directamente, lo que favorece la reutilización de código y el mantenimiento. Son especialmente útiles cuando tienes:

  • Navegación repetitiva desde varios puntos de la app.
  • Necesidad de mayor mantenibilidad a largo plazo.
  • Rutas bien estructuradas y centralizadas.
  • Parámetros, filtros o pantallas de detalle que se invocan desde múltiples lugares.

Definición de rutas con nombre

Las rutas se definen como un Map<String, WidgetBuilder> y se pasan a la propiedad routes de MaterialApp:

void main() {
  runApp(MaterialApp(
    title: 'My App',
    home: Main(),
    routes: {
      '/page': (context) => PageRoute(),
    },
  ));
}

Uso de rutas con nombre

En lugar de Navigator.push(), usas Navigator.pushNamed() para navegar a una ruta por su nombre. De manera similar, Navigator.pushReplacementNamed() reemplaza la ruta actual sin dejarla en el stack. El método Navigator.pop() funciona igual para todos los tipos de ruta.

class Main extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Main Route'),
      ),
      body: Center(
        child: ElevatedButton(
          child: Text('Open route'),
          onPressed: () {
            Navigator.pushReplacementNamed(context, '/page');
          },
        ),
      ),
    );
  }
}

Nota: RaisedButton fue deprecado en versiones recientes de Flutter. Usa ElevatedButton en su lugar.

Navigator 2.0: navegación declarativa y manejo del historial

Qué resuelve Navigator 2.0 y por qué es clave en Flutter Web

Navigator 2.0 (también llamado la API declarativa del Router) nació para resolver escenarios que Navigator 1.0 no podía manejar de forma elegante:

  • Deep linking complejo con rutas anidadas.
  • Sincronización bidireccional entre el estado de la UI y la URL del navegador.
  • Flujos de navegación no lineales (ej. wizards con pasos opcionales).
  • Mayor control programático del historial de navegación.

En una app web que construí, usar RouterDelegate me permitió que la URL del navegador siempre reflejara el estado real de la navegación, algo imprescindible para el SEO y la experiencia del usuario en web.

RouterDelegate y RouteInformationParser en palabras simples

Navigator 2.0 se apoya en dos piezas clave:

  • RouteInformationParser: interpreta la URL entrante y la convierte en un objeto de estado que tu app puede entender.
  • RouterDelegate: recibe ese estado y decide qué pantallas mostrar en el stack. Es quien tiene el control total del árbol de rutas.

Caso práctico: navegación basada en URL y deep linking

El deep linking permite que alguien abra tu app directamente en una ruta específica, ya sea desde un enlace externo, una notificación push o un correo electrónico. Por ejemplo:

  • /producto/123
  • /perfil/editar
  • /tarea/7

Navigator 2.0 lo soporta de forma nativa

Dicho esto, configurar Navigator 2.0 manualmente puede volverse verboso y complejo rápidamente. Es por eso que en la mayoría de los proyectos se recomienda usar go_router, que abstrae toda esa complejidad manteniendo la potencia de Navigator 2.0 debajo del capó.

go_router en Flutter: navegación moderna y declarativa

Por qué go_router simplifica la navegación en apps grandes

Aquí viene la parte que cambió mi vida como desarrollador Flutter: go_router.

En mis proyectos grandes, organizados por features, noté que Navigator 1.0 se volvía inmanejable a medida que crecía el número de pantallas. go_router me dio exactamente lo que necesitaba:

  • Rutas declarativas definidas en un solo lugar.
  • URLs limpias y predecibles.
  • Parámetros de ruta y query params más claros.
  • Rutas anidadas fáciles de implementar.
  • Redirecciones condicionales poderosas.

Definición declarativa de rutas y parámetros dinámicos

Con go_router defines cada ruta usando GoRoute y accedes a los parámetros de la URL directamente desde state.pathParameters:

GoRoute(
  path: 'details/:id',
  builder: (_, state) => DetalleScreen(id: state.pathParameters['id']!),
);

Uso de context.go(), context.goNamed() y extracción de parámetros

Para navegar con go_router tienes dos opciones principales:

  • context.go('/detalles/8'): navega directamente usando el path.
  • context.goNamed('detalle', pathParameters: {'id': '8'}): navega usando el nombre de la ruta (más seguro y escalable).

Redirecciones, protección de rutas y control del estado

Una de las características más poderosas de go_router es la posibilidad de proteger rutas mediante el parámetro redirect. Si el usuario no está autenticado, lo envías al login automáticamente:

redirect: (_, state) {
  if (!estaLogueado) return '/login';
  return null;
}

Retornar null significa que no hay redirección y la ruta se carga normalmente. Esta lógica puede ser tan compleja como necesites, consultando el estado de tu gestor de estado favorito (Provider, Riverpod, Bloc, etc.).

Transiciones personalizadas con go_router

Puedes animar cada ruta de forma individual usando CustomTransitionPage junto con transitionsBuilder. Esto te da control total sobre el tipo de animación (fade, slide, scale) sin necesidad de abandonar la API declarativa de go_router.

Rutas anidadas y organización del proyecto por features

Cómo estructurar módulos y rutas hijas con go_router

Un caso de uso muy habitual es el de una lista de tareas con sus pantallas hijas: detalle de tarea y pantalla para agregar una nueva. Con go_router puedes declarar rutas hijas de forma limpia:

GoRoute(
  path: '/',
  builder: (_, __) => ListaTareasScreen(),
  routes: [
    GoRoute(
      path: 'details/:taskId',
      builder: (_, state) => DetalleTareaScreen(
        taskId: state.pathParameters['taskId']!,
      ),
    ),
    GoRoute(
      path: 'nueva',
      builder: (_, __) => NuevaTareaScreen(),
    ),
  ],
);

Caso práctico: lista de tareas con detalles y pantalla de creación

Cuando implementé una app de tareas con esta estructura, descubrí algo que cambió mi forma de organizar proyectos:

  • La lista de tareas es la ruta padre (/).
  • El detalle de una tarea es la ruta hija (/details/:taskId).
  • Añadir una tarea nueva es otra ruta hija (/nueva).
  • Todo queda legible, modular y fácil de mantener.

Los beneficios de la navegación anidada en apps escalables son claros:

  • Mantenimiento más fácil: cada feature gestiona sus propias rutas.
  • URLs predecibles y significativas.
  • Separación por features que facilita el trabajo en equipo.
  • Control total del flujo de navegación sin código espagueti.

Comparativa completa: Navigator vs Navigator 2.0 vs go_router

Qué elegir según el tamaño y complejidad de tu aplicación

No existe una respuesta única. La elección depende del contexto de tu proyecto:

EscenarioMejor opción
App pequeña o prototipoNavigator 1.0
App mediana con estados simplesRutas con nombre (pushNamed)
App grande o modulargo_router
App Flutter Web con URLsNavigator 2.0 o go_router
Deep linking avanzadogo_router o Navigator 2.0

Tabla de casos de uso reales y solución recomendada

Caso realSolución recomendada
Evitar regresar al login tras autenticaciónNavigator.pushReplacement() o redirect en go_router
Navegación por URL en Flutter Webgo_router / Navigator 2.0
Flujos tipo checkout (limpiar stack)Navigator.pushAndRemoveUntil()
Rutas hijas con parámetrosgo_router
SPA estilo web con navegación declarativago_router

Buenas prácticas para evitar errores comunes de navegación

  • No mezcles Navigator 1.0 y go_router sin necesidad: elige uno y sé consistente.
  • Mantén las definiciones de rutas fuera de los widgets de UI, en archivos o clases dedicadas.
  • Usa nombres de rutas en lugar de paths hardcodeados: si cambias una URL, solo la actualizas en un lugar.
  • Evita acoplar el estado de negocio a las rutas: la navegación debe ser una consecuencia del estado, no su causa.

Buenas prácticas y patrones para una navegación robusta

Organización de rutas por módulos o features

En apps medianas o grandes, siempre organizo las rutas por feature. Una estructura típica que uso es:

/features/tareas
  /presentation    ← widgets y pantallas
  /routes          ← definición de GoRoute para esta feature
  /data            ← repositorios y modelos

Esto hace que cada feature sea autónoma y que añadir o eliminar un módulo no afecte al resto.

Manejo de estado en flujos de navegación

El gestor de estado que elijas influye directamente en cómo proteges rutas y pasas datos. Mi recomendación según el tamaño del proyecto:

  • Proyecto pequeño → setState es suficiente.
  • Proyecto mediano → Provider para estado global sencillo.
  • Proyecto grande → Riverpod o Bloc para control granular y testeable.

Limpieza de código y arquitectura recomendada

  • Separa la lógica de navegación de la UI: tus widgets no deberían saber cómo está definido el router.
  • Usa modelos tipados para pasar parámetros complejos entre rutas, en lugar de Map<String, dynamic> genéricos.
  • Evita colocar lógica de negocio dentro de los builder de las rutas; delega esa responsabilidad a los widgets o a la capa de presentación.

Por qué no debes usar push() y sí pushReplacement() al navegar entre cursos

Video thumbnail

Índice de contenido

Quería compartir una anécdota real que me pasó mientras construía la aplicación DesarrolloLibre, la misma que estás viendo en pantalla ahora mismo.

El problema surgió con el componente DrawerComponent, que es el que permite navegar entre las diversas secciones de la aplicación. Específicamente, el inconveniente aparecía en el listado de todos los cursos: cuando entraba al detalle de un curso y luego quería ir al detalle de otro, tenía que abrir el DrawerComponent y tocar "Todos los cursos" de nuevo para reiniciar la lista.

El problema con Navigator.push()

Antes, estaba usando simplemente:

Navigator.push(context, MaterialPageRoute(builder: (_) => DetalleCursoScreen()));

El problema con Navigator.push() es que encola cada una de las vistas anteriores en el stack. Por lo tanto, si el usuario navegaba entre varios cursos, al presionar "Atrás" regresaba al curso anterior en lugar de al listado. Si revisaba 10 cursos, se acumulaban 10 rutas en el stack.

Esto generaba además un problema más grave: cuando se ejecutaba dispose() en el widget del curso actual, el estado de la página se mezclaba con el de las páginas anteriores todavía en el stack. Esto provocaba que la aplicación crasheara al intentar acceder fuera del rango a alguno de los listados de clases.

Solución con Navigator.pushReplacement()

Lo importante es identificar qué necesita realmente tu aplicación para funcionar de forma correcta.

En mi caso, no tenía ningún sentido mantener encoladas todas las vistas de cursos anteriores, ya que solo consumían memoria y tiempo de render innecesarios.

La solución fue reemplazar cada navegación entre cursos con:

Navigator.pushReplacement(
  context,
  MaterialPageRoute(builder: (_) => DetalleCursoScreen()),
);

Con Navigator.pushReplacement(), cada vez que el usuario navega a un nuevo curso, la pantalla anterior se reemplaza en lugar de apilarse. El stack no crece, el dispose() se ejecuta correctamente y los problemas de estado desaparecen por completo.

Preguntas frecuentes sobre navegación en Flutter

  • ¿Diferencia entre context.go() y context.goNamed()?
    • context.go('/ruta'): navega directamente usando el path como string.
    • context.goNamed('nombre'): navega usando el nombre de la ruta. Es más seguro y escalable porque el path puede cambiar sin romper la navegación.
  • ¿Cómo proteger rutas con redirecciones?
    • Con el parámetro redirect en go_router, o mediante guardas personalizadas en RouterDelegate si usas Navigator 2.0 directamente.
  • ¿Cómo pasar datos adicionales a una ruta sin exponerlos en la URL?
    • Usa el parámetro extra de go_router:
    • context.goNamed('detalle', extra: {'modo': 'edicion'});
    • Ten en cuenta que extra no sobrevive a un reload de página en Flutter Web, así que para datos que deben persistir en la URL usa pathParameters o queryParameters.
  • ¿Cómo controlar el historial en Flutter Web?
    • Usa navegación declarativa (go_router o Navigator 2.0) y evita manipular el stack manualmente con push/pop. El historial del navegador se sincronizará automáticamente con tu stack de rutas.
  • ¿Cuándo usar Navigator.pushAndRemoveUntil()?
    • Cuando necesitas limpiar todo el historial de navegación, como al cerrar sesión o al completar un flujo de onboarding. Puedes limpiar todo el stack con (route) => false como predicado, o hasta una ruta específica con ModalRoute.withName('/home').

Conclusión: cómo elegir la mejor estrategia de navegación para tu app Flutter

La navegación es más que ir de una pantalla a otra: es la columna vertebral del flujo de tu app. Con Navigator 1.0 puedes avanzar rápido en proyectos pequeños; con Navigator 2.0 ganas control total sobre el historial; y con go_router consigues una navegación moderna, declarativa y escalable que no se vuelve en tu contra cuando el proyecto crece.

En mi experiencia construyendo apps con listas, detalles, login, flujos de pago y versiones web, lo que más me ha funcionado es:

  • Navigator 1.0 para prototipos y apps de una o dos pantallas.
  • go_router para cualquier cosa medianamente seria, desde apps pequeñas con deep linking hasta SPAs complejas.
  • Navigator 2.0 solo cuando necesito control quirúrgico del historial o integración muy específica con el historial del navegador en web.

Dominar las tres herramientas te hará más rápido, más flexible y te permitirá crear apps robustas sin enredarte en la navegación.

Siguiente paso: descubre todo sobre el Widget Scaffold de Flutter.

stas sin enredarte en la navegación.

Domina la navegación en Flutter: Navigator.push(), pushReplacement(), pushAndRemoveUntil(), popUntil() y go_router. Guía práctica con ejemplos reales, tablas comparativas y buenas prácticas.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.