Navigation and routing

Flutter · Practical · 14 min

Every multi-screen app needs navigation, and deep links and web URLs require declarative routing.

The `Navigator` manages a stack of routes (screens).

  • Navigator.push(context, MaterialPageRoute(builder: (_) => DetailScreen(id: 3))) shows a new screen; arguments are just constructor parameters.
  • Navigator.pop(context, result) returns to the previous screen, optionally with a value. push returns a Future that completes with that result, so final picked = await Navigator.push(...) works for pickers.
  • pushReplacement swaps the current screen (e.g. after login); pushAndRemoveUntil clears the stack.
  • showDialog and showModalBottomSheet also push routes and return futures.

This imperative API is fine for simple apps. For deep links, web URLs, redirects (auth guards) and nested navigation such as bottom tabs that keep their own stacks, use a declarative router. The Flutter team maintains `go_router`:

final router = GoRouter(routes: [
  GoRoute(path: '/', builder: (c, s) => const HomeScreen()),
  GoRoute(path: '/item/:id', builder: (c, s) =>
      ItemScreen(id: s.pathParameters['id']!)),
]);
MaterialApp.router(routerConfig: router);

Navigate with context.go('/item/42') (replace the stack to match the URL) or context.push('/item/42') (add on top). redirect implements login guards, and StatefulShellRoute keeps per-tab stacks.

On Android, PopScope intercepts the system back gesture, e.g. to confirm discarding a draft. After awaiting a pushed route, check context.mounted before using context again.

Example

import 'package:flutter/material.dart';
class ColorPicker extends StatelessWidget {
  const ColorPicker({super.key});
  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text('Pick a color')),
        body: ListView(children: [
          for (final (name, color) in [('Red', Colors.red), ('Blue', Colors.blue)])
            ListTile(title: Text(name), onTap: () => Navigator.pop(context, color)),
        ]),
      );
}
class Home extends StatelessWidget {
  const Home({super.key});
  @override
  Widget build(BuildContext context) => FilledButton(
        child: const Text('Choose color'),
        onPressed: () async {
          final color = await Navigator.push<Color>(
              context, MaterialPageRoute(builder: (_) => const ColorPicker()));
          if (!context.mounted || color == null) return;   // user may have gone back
          ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Got $color')));
        },
      );
}

Remember

  • push returns a Future completed by pop(context, result)
  • Pass data to screens through constructor parameters
  • Use go_router for URLs, deep links, redirects and tab stacks

Watch out for

  • Using context after awaiting a route without checking context.mounted.

Check yourself

  1. What does `await Navigator.push(...)` return?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.flutter.dev/ui/navigation