Navigation moves users between screens. Flutter has two approaches: Navigator for simple push/pop navigation, and go_router for URL-based routing with path parameters, bottom navigation, and auth guards. Most apps should use go_router.

This guide assumes you know widgets. If not, start with Widgets. For the full routing guide, see the official Flutter navigation docs.

Navigator

Navigator works like a stack of cards. You push a new screen on top, and pop it off to go back. It is built into Flutter with no extra packages needed.

dart
// Push a new screen onto the stack
Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => DetailScreen()),
);

// Pop the current screen to go back
Navigator.pop(context);

// Push a screen and remove everything behind it (e.g., after login)
Navigator.pushAndRemoveUntil(
  context,
  MaterialPageRoute(builder: (context) => HomeScreen()),
  (route) => false,
);

Passing Data

Pass data to the next screen through its constructor, just like any other widget.

dart
class DetailScreen extends StatelessWidget {
  final String itemId;

  const DetailScreen({super.key, required this.itemId});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Item $itemId')),
    );
  }
}

// Navigate and pass data
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => DetailScreen(itemId: '123'),
  ),
);

Returning Data

A screen can send data back when it pops. The calling screen awaits the result.

dart
// The screen being popped sends a result back
Navigator.pop(context, 'selected_value');

// The calling screen waits for it
final result = await Navigator.push<String>(
  context,
  MaterialPageRoute(builder: (context) => SelectionScreen()),
);
// result is 'selected_value', or null if the user pressed back

go_router

go_router is a routing package that gives you URL-based navigation, path parameters, query strings, redirects, and bottom navigation with preserved state. It is the recommended approach for any app beyond a couple of screens. Add it to your pubspec.yaml:

yaml
dependencies:
  go_router: ^14.0.0

Setup

Define your routes as a GoRouter object. Each GoRoute maps a URL path to a screen widget.

dart
import 'package:go_router/go_router.dart';

final router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomeScreen(),
    ),
    GoRoute(
      path: '/details/:id',
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return DetailScreen(id: id);
      },
    ),
    GoRoute(
      path: '/settings',
      builder: (context, state) => SettingsScreen(),
    ),
  ],
);

Then use MaterialApp.router instead of the regular MaterialApp to connect the router:

dart
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
    );
  }
}

Navigation

go_router gives you two navigation methods. context.go is declarative — it navigates to a path and rebuilds the entire navigation stack to match. Use it for top-level navigation (switching tabs, going to home after login). context.push is imperative — it adds a screen on top of whatever is already there, so the user can press back. Use it for detail screens.

dart
// Navigate declaratively (replaces the stack)
context.go('/details/123');

// Navigate imperatively (pushes on top, can go back)
context.push('/details/123');

// Go back
context.pop();

// Replace the current screen
context.pushReplacement('/home');

Path Parameters

Use :name in the path to capture dynamic segments. Read them from state.pathParameters in the builder. Path parameters are part of the URL itself, like /user/123.

Query Parameters

Query parameters come after a ? in the URL, like /search?q=flutter. Use them for optional filters or search terms.

dart
// Navigate with a query parameter
context.go('/search?q=flutter');

// Read it in the route builder
GoRoute(
  path: '/search',
  builder: (context, state) {
    final query = state.uri.queryParameters['q'] ?? '';
    return SearchScreen(query: query);
  },
)

Passing Objects

For complex data that does not belong in the URL, use extra to pass any Dart object directly.

dart
// Pass an object
context.push('/details', extra: myItem);

// Read it in the builder
GoRoute(
  path: '/details',
  builder: (context, state) {
    final item = state.extra as Item;
    return DetailScreen(item: item);
  },
)

Bottom Navigation

go_router handles bottom navigation with StatefulShellRoute. Each tab is a StatefulShellBranch with its own routes and navigation stack. This means switching tabs preserves scroll position and screen history, instead of resetting everything.

dart
final router = GoRouter(
  initialLocation: '/home',
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return ScaffoldWithNav(navigationShell: navigationShell);
      },
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/home',
              builder: (context, state) => HomeScreen(),
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/search',
              builder: (context, state) => SearchScreen(),
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/profile',
              builder: (context, state) => ProfileScreen(),
            ),
          ],
        ),
      ],
    ),
  ],
);

The ScaffoldWithNav widget wraps the shell with a NavigationBar. The navigationShellis the current tab's content. Use goBranch to switch tabs.

dart
class ScaffoldWithNav extends StatelessWidget {
  final StatefulNavigationShell navigationShell;

  const ScaffoldWithNav({super.key, required this.navigationShell});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: navigationShell.currentIndex,
        onDestinationSelected: (index) {
          navigationShell.goBranch(index);
        },
        destinations: [
          NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
          NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
          NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }
}

Redirects

Use redirects to guard routes based on auth state. The redirect function runs before every navigation. Return a path to redirect, or null to allow the navigation to proceed normally.

dart
final router = GoRouter(
  redirect: (context, state) {
    final isLoggedIn = authService.isLoggedIn;
    final isOnLogin = state.matchedLocation == '/login';

    // Not logged in and not on login page → send to login
    if (!isLoggedIn && !isOnLogin) return '/login';

    // Logged in but on login page → send to home
    if (isLoggedIn && isOnLogin) return '/home';

    // Otherwise, let the navigation happen
    return null;
  },
  routes: [...],
);