Navigation & Routing
Flutter navigation — Navigator 1.0/2.0, go_router, type-safe routes, nested navigation, deep linking, web URLs, and testing
Navigation & Routing
How to move between screens without your app's navigation turning into spaghetti. The guiding principle: declare your routes in one place, make navigation state a single source of truth, and treat deep links as a first-class entry point — not an afterthought.
Imperative Navigation (Navigator 1.0)
Navigator 1.0 models navigation as a stack you mutate imperatively with push and pop. It is fine for prototypes and simple flows, but it does not scale.
Push and Pop
// Push a new screen onto the stack
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const OrderPage()),
);
// Pop back
Navigator.of(context).pop();Named Routes
MaterialApp(
routes: {
'/': (_) => const HomePage(),
'/settings': (_) => const SettingsPage(),
},
);
Navigator.of(context).pushNamed('/settings');Passing Arguments
Navigator.of(context).pushNamed('/order', arguments: orderId);
// In the destination
final orderId = ModalRoute.of(context)!.settings.arguments as String;Arguments are untyped Object? — a runtime cast that the compiler cannot check.
Returning a Result
push returns a Future that completes when the route is popped:
final picked = await Navigator.of(context).push<Color>(
MaterialPageRoute(builder: (_) => const ColorPickerPage()),
);
if (!context.mounted) return; // always re-check after the await
if (picked != null) setState(() => _color = picked);
// On the picker page
Navigator.of(context).pop(Colors.teal);Always re-check context.mounted after an await. The user may pop the page while the awaited future is still pending, leaving you holding a dead BuildContext. See Best Practices for the broader rule.
onGenerateRoute
onGenerateRoute is the escape hatch for argument parsing and dynamic routes with the named-routes API:
MaterialApp(
onGenerateRoute: (settings) {
switch (settings.name) {
case '/order':
final id = settings.arguments as String;
return MaterialPageRoute(builder: (_) => OrderPage(id: id));
default:
return MaterialPageRoute(builder: (_) => const NotFoundPage());
}
},
);Why It Doesn't Scale
| Concern | Navigator 1.0 reality |
|---|---|
| Type safety | Arguments are Object?, cast at runtime |
| Deep linking | You hand-parse URLs in onGenerateRoute |
| Web URLs | The address bar does not reflect the stack |
| Auth guards | Scattered if checks before every push |
| Nested navigation | Manual Navigator keys, fragile back handling |
Navigator 2.0 / the Router API
Navigator 2.0 introduced a declarative model: a list of Page objects describes the entire stack, and the framework diffs it for you. The moving parts are:
RouterDelegate— builds theNavigatorfrom your app state.RouteInformationParser— converts a URL into your app state and back.Pages— an immutable list describing the current stack.
It is powerful — full control over the stack, native deep-link and browser-history integration — but extremely verbose. A trivial two-screen app can require 150+ lines of boilerplate.
You rarely write Navigator 2.0 by hand. Almost every production app uses a package built on top of it. go_router is the official, Flutter-team-maintained option and the one we recommend below.
go_router (Recommended)
go_router gives you a declarative route tree, URL parsing, redirects, nested navigation, and deep linking — all with far less boilerplate than raw Navigator 2.0.
# pubspec.yaml
dependencies:
go_router: ^14.0.0Basic Setup
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
routes: [
GoRoute(
path: 'order/:id', // nested -> /order/:id
builder: (context, state) => OrderPage(
id: state.pathParameters['id']!,
),
),
],
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(routerConfig: router);
}
}Navigate with context.go (replace the stack to a location) or context.push (stack a new route on top):
context.go('/order/42'); // declarative navigation, URL becomes /order/42
context.push('/order/42'); // imperative-style push, keeps the back stackPath and Query Parameters
GoRoute(
path: '/search',
builder: (context, state) {
final query = state.uri.queryParameters['q'] ?? '';
final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1;
return SearchPage(query: query, page: page);
},
);
context.go('/search?q=flutter&page=2');Type-Safe Routes with go_router_builder
Hand-written string paths are a refactor hazard. go_router_builder generates type-safe route classes from annotations:
dependencies:
go_router: ^14.0.0
dev_dependencies:
build_runner: ^2.4.0
go_router_builder: ^2.7.0// routes.dart
part 'routes.g.dart';
@TypedGoRoute<HomeRoute>(
path: '/',
routes: [
TypedGoRoute<OrderRoute>(path: 'order/:id'),
],
)
class HomeRoute extends GoRouteData {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const HomePage();
}
class OrderRoute extends GoRouteData {
const OrderRoute({required this.id});
final String id; // path param parsed for you
@override
Widget build(BuildContext context, GoRouterState state) =>
OrderPage(id: id);
}// Navigate type-safely — a typo or wrong type is a compile error
const OrderRoute(id: '42').go(context);
const OrderRoute(id: '42').push(context);Run dart run build_runner build to generate routes.g.dart.
Auth Guards with redirect
redirect runs before a route is built and can divert the user. Return null to allow, or a path string to redirect:
final router = GoRouter(
refreshListenable: authNotifier, // re-run redirect on auth change
redirect: (context, state) {
final loggedIn = authNotifier.isLoggedIn;
final loggingIn = state.matchedLocation == '/login';
if (!loggedIn && !loggingIn) return '/login'; // gate everything
if (loggedIn && loggingIn) return '/'; // bounce away from login
return null; // allow
},
routes: [...],
);refreshListenable accepts any Listenable (e.g. a ChangeNotifier wrapping your auth state). When it notifies, go_router re-evaluates redirect, so a logout instantly kicks the user back to /login.
Keep redirect pure and cheap. It runs on every navigation. Read already-resolved state (a cached isLoggedIn flag) — never trigger network calls or async work inside it.
Error / 404 Page
GoRouter(
errorBuilder: (context, state) => NotFoundPage(uri: state.uri),
routes: [...],
);errorBuilder catches unmatched paths and exceptions thrown during route building.
Nested Navigation & Persistent Bottom Nav
A bottom navigation bar that preserves each tab's scroll position and back stack is the canonical use case for StatefulShellRoute.indexedStack. Each branch keeps its own Navigator, and an IndexedStack keeps all branches alive:
final router = GoRouter(
initialLocation: '/home',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
ScaffoldWithNavBar(navigationShell: navigationShell),
branches: [
StatefulShellBranch(routes: [
GoRoute(path: '/home', builder: (_, __) => const HomePage()),
]),
StatefulShellBranch(routes: [
GoRoute(path: '/cart', builder: (_, __) => const CartPage()),
]),
StatefulShellBranch(routes: [
GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
]),
],
),
],
);
class ScaffoldWithNavBar extends StatelessWidget {
const ScaffoldWithNavBar({required this.navigationShell, super.key});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) => navigationShell.goBranch(
index,
// tapping the active tab pops it to its root
initialLocation: index == navigationShell.currentIndex,
),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.shopping_cart), label: 'Cart'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}indexedStack keeps every branch mounted, so switching tabs preserves their state. Use StatefulShellRoute (without .indexedStack) if you want branches lazily disposed instead.
Custom Transitions
Wrap the destination in a CustomTransitionPage and return it from pageBuilder instead of builder:
GoRoute(
path: '/detail',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const DetailPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 250),
),
);For a no-animation tab swap, return a NoTransitionPage.
Returning Values with go_router
context.push is generic and returns a Future that completes when the pushed route pops:
final result = await context.push<bool>('/confirm');
if (!context.mounted) return;
if (result == true) deleteItem();
// On the confirm page
context.pop(true);Only push returns a value — go does not. go replaces the location rather than stacking on top, so there is no caller awaiting a result. Use push when you need an answer back.
Deep Linking
Deep links let a URL open a specific screen directly. go_router parses the incoming URL through your route tree automatically — your job is the platform-side configuration.
App Links vs Custom URL Schemes
| App Links / Universal Links | Custom URL scheme | |
|---|---|---|
| Example | https://app.example.com/order/42 | myapp://order/42 |
| Verification | Requires hosting a verification file on your domain | None |
| Hijacking | Cannot be claimed by another app | Any app can register the same scheme |
| Recommendation | Preferred for production | Fine for internal/dev links |
Android (App Links)
<!-- android/app/src/main/AndroidManifest.xml, inside <activity> -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.example.com" />
</intent-filter>Host https://app.example.com/.well-known/assetlinks.json to verify ownership.
iOS (Universal Links)
<!-- ios/Runner/Info.plist -->
<key>FlutterDeepLinkingEnabled</key>
<true/>Add the Associated Domains capability in Xcode with applinks:app.example.com, and host https://app.example.com/.well-known/apple-app-site-association.
Handling the Initial Deep Link
go_router reads the launch URL automatically via initialLocation resolution — a cold start from a deep link lands on the right route with no extra code. Validate any IDs or tokens carried in the URL before acting on them; see Security for input-validation guidance on untrusted deep-link payloads.
Web URL Strategy
By default Flutter web uses hash URLs (example.com/#/order/42). Switch to clean path URLs:
import 'package:flutter_web_plugins/url_strategy.dart';
void main() {
usePathUrlStrategy(); // example.com/order/42 instead of /#/order/42
runApp(const MyApp());
}| Strategy | URL | Server requirement |
|---|---|---|
| Hash (default) | example.com/#/order/42 | None — works on any static host |
Path (usePathUrlStrategy) | example.com/order/42 | Server must rewrite all paths to index.html |
With MaterialApp.router, go_router keeps the browser address bar in sync, and the browser's back/forward buttons drive the route stack correctly — no extra wiring needed.
Path strategy needs a SPA fallback. Configure your host (Nginx try_files, Firebase Hosting rewrites, Netlify _redirects) to serve index.html for unknown paths, or a hard refresh on a deep route returns a 404.
Testing Navigation
Pump a GoRouter inside MaterialApp.router and assert on the rendered destination:
testWidgets('navigates to order detail on tap', (tester) async {
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(
path: '/order/:id',
builder: (_, state) => OrderPage(id: state.pathParameters['id']!),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
expect(find.byType(HomePage), findsOneWidget);
await tester.tap(find.byKey(const Key('order-42-tile')));
await tester.pumpAndSettle();
expect(find.byType(OrderPage), findsOneWidget);
expect(find.text('Order 42'), findsOneWidget);
});You can also drive the router directly with router.go('/order/42') and pump, which is useful for testing redirects in isolation. See Testing for the broader strategy.
Anti-Patterns
- Scattering
Navigator.pushacross the codebase. Hand-written pushes in dozens of widgets make permission checks, deep linking, and back-stack reasoning impossible. Centralize every route in oneGoRouterdefinition. - Abusing
GlobalKey<NavigatorState>. Reaching for a global navigator key to push from anywhere bypasses the router and breaks deep linking and web history. Usecontext.go/context.push, and only keep a navigator key for the rare case go_router itself requires one (e.g. a root key for dialogs above the shell). - Putting business or auth logic inside route builders. A
buildershould construct a widget, nothing more. Auth gating belongs inredirect; data loading belongs in the page's view model — not in anifblock wrapped around the builder. - Ignoring deep links until launch week. Retrofitting App Links, URL parsing, and cold-start handling onto a finished app is painful. Wire the route tree as URLs from day one so deep linking is essentially free.
- Storing navigation state in multiple places. A "current tab" int in one widget, a route string in another, and a Navigator stack underneath inevitably drift out of sync. Let the router's location be the single source of truth and derive UI from it.
- Using
gowhen you need a result.goreplaces the stack and never returns a value; awaiting it silently never completes. Usepushwhenever the caller expects something back.