Navigation and routing
Moving between screens: the imperative Navigator API, declarative routing with
go_router, custom transitions, and deep links.
Contents
- Navigator 1.0
- Passing and returning data
- Why Navigator 2.0 exists
- go_router
- Nested navigation
- Route guards
- Custom transitions
- Deep links
- Testing navigation
Navigator 1.0
A stack of routes, pushed and popped imperatively.
// Push
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ProductPage(id: id)),
);
// Pop
Navigator.of(context).pop();
// Replace the current route
Navigator.of(context).pushReplacement(route);
// Clear the stack down to a condition
Navigator.of(context).pushAndRemoveUntil(route, (r) => r.isFirst);
Named routes registered on MaterialApp still work and are fine for small apps:
MaterialApp(
routes: {
'/': (context) => const HomePage(),
'/settings': (context) => const SettingsPage(),
},
)
Navigator.of(context).pushNamed('/settings');
They stop scaling as soon as you need typed parameters, nested navigators, or URL synchronisation on the web.
Passing and returning data
Constructor arguments are the typed, refactor-safe way to pass data in:
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ProductPage(product: product)),
);
A route returns a value through pop, and push completes with it:
final selected = await Navigator.of(context).push<Product>(
MaterialPageRoute(builder: (_) => const ProductPicker()),
);
if (selected != null) {
// use it
}
// Inside ProductPicker
Navigator.of(context).pop(product);
Because push is asynchronous, guard BuildContext use afterwards:
final result = await showDialog<bool>(context: context, builder: ...);
if (!context.mounted) return; // the widget may be gone
ScaffoldMessenger.of(context).showSnackBar(...);
Forgetting context.mounted after an await is one of the most common runtime
errors in Flutter apps.
Why Navigator 2.0 exists
Navigator 1.0 cannot express "the stack is a function of app state". That
matters for the browser back button, deep links that restore a whole stack, and
restoring state after the process is killed. Navigator 2.0 introduced Router,
RouteInformationParser, and RouterDelegate to make the stack declarative.
The raw API is verbose enough that most projects use a package built on it.
Understanding the concept is useful; writing a RouterDelegate by hand rarely
is.
go_router
flutter pub add go_router
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
routes: [
GoRoute(
path: 'product/:id', // relative to parent
builder: (context, state) => ProductPage(
id: state.pathParameters['id']!,
highlight: state.uri.queryParameters['highlight'],
),
),
],
),
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
],
errorBuilder: (context, state) => NotFoundPage(error: state.error),
);
MaterialApp.router(routerConfig: router);
Navigating:
context.go('/product/42'); // replace the stack with this location
context.push('/product/42'); // push on top, keeps back history
context.pop();
context.goNamed('product', pathParameters: {'id': '42'});
go versus push is the distinction people get wrong: go sets the location
(the stack is rebuilt from the route tree), push adds one route on top. Use
go for navigation the URL should describe, push for transient screens.
Named routes with name: on each GoRoute avoid string paths scattered through
the codebase; a _Routes class of constants works equally well.
Nested navigation
StatefulShellRoute keeps a persistent shell — a bottom navigation bar or a
side rail — with an independent stack per branch, so switching tabs preserves
each tab's history and scroll position.
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
ScaffoldWithNavBar(navigationShell: navigationShell),
branches: [
StatefulShellBranch(routes: [
GoRoute(path: '/feed', builder: (c, s) => const FeedPage()),
]),
StatefulShellBranch(routes: [
GoRoute(path: '/profile', builder: (c, s) => const ProfilePage()),
]),
],
)
In the shell widget, switch branches with
navigationShell.goBranch(index, initialLocation: index == navigationShell.currentIndex)
so tapping the active tab returns to its root.
Use plain ShellRoute when the shell has no per-branch state to preserve.
Route guards
redirect runs before a route builds. Return a location to redirect, or null
to proceed.
GoRouter(
refreshListenable: authNotifier, // re-evaluates redirects on change
redirect: (context, state) {
final loggedIn = authNotifier.isLoggedIn;
final goingToLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingToLogin) {
return '/login?from=${state.matchedLocation}';
}
if (loggedIn && goingToLogin) {
return '/';
}
return null;
},
routes: [...],
)
refreshListenable is what makes the guard reactive — without it, a logout does
not push the user off a protected page. Keep the redirect pure and cheap; it
runs on every navigation.
Custom transitions
With go_router, return a CustomTransitionPage from pageBuilder:
GoRoute(
path: '/details',
pageBuilder: (context, state) => CustomTransitionPage<void>(
key: state.pageKey,
child: const DetailsPage(),
transitionDuration: const Duration(milliseconds: 250),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: child,
),
),
)
With Navigator 1.0, use PageRouteBuilder with the same
transitionsBuilder signature. animation drives the incoming route;
secondaryAnimation drives the outgoing one, which is what you animate when a
new route covers this one.
To disable transitions in tests or for reduced-motion users, supply a
transitionsBuilder that returns child unchanged.
Deep links
A deep link opens the app directly at a location. Three pieces are needed: platform configuration, a route that matches the path, and testing.
Android — in android/app/src/main/AndroidManifest.xml, inside the main
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="example.com" />
</intent-filter>
App Links additionally require assetlinks.json served from
https://example.com/.well-known/assetlinks.json.
iOS — enable the Associated Domains capability with
applinks:example.com, and serve
https://example.com/.well-known/apple-app-site-association as JSON with no
file extension.
Testing:
# Android
adb shell am start -a android.intent.action.VIEW -d "https://example.com/product/42"
# iOS simulator
xcrun simctl openurl booted "https://example.com/product/42"
go_router handles the incoming URI automatically because its routes are paths.
Verify both cold start (app not running) and warm start (app in background) —
they take different code paths and warm start is the one that usually breaks.
Testing navigation
Build the router in a test and drive it with a WidgetTester:
testWidgets('opens product details', (tester) async {
await tester.pumpWidget(MaterialApp.router(routerConfig: buildRouter()));
await tester.tap(find.text('Espresso Machine'));
await tester.pumpAndSettle();
expect(find.byType(ProductPage), findsOneWidget);
});
Keep the router construction in a function that accepts its dependencies, so a test can supply a fake auth notifier and exercise the redirect logic directly. See Testing.
Next: Async and isolates.