Navigator is a widget that manages a stack of Route objects: push puts a new screen on top, pop removes the top one and shows the screen underneath again.
Route: one entry on the stack; MaterialPageRoute or CupertinoPageRoute builds the page and gives it the platform transition
Where it comes from: MaterialApp creates the root Navigator; Navigator.of(context) finds the nearest one above your widget
Back: the system back button and the app bar arrow call maybePop, which does not pop the last route (the system then closes the app); the iOS swipe pops directly
**ModalRoute.of(context)**: the route the current widget lives in: its settings, isCurrent and transition animation
Results: push returns a Future that completes with the value passed to pop
FlutterJunior
Navigation & Routing
Navigator, named routes, go_router, passing data between screens
Questions closed
0 / 24
- 0Review
- 6Asked often
Questions
push adds a route on top, pushReplacement swaps the top route, pushAndRemoveUntil pushes and drops routes below until a predicate says stop, and popUntil pops back to a route already on the stack. After login you use pushAndRemoveUntil(home, (r) => false).
**pushReplacement: the old top route is gone, so back cannot return to it; enough when login was the only screen
pushAndRemoveUntil**: the predicate (r) => false removes everything, (r) => r.isFirst keeps the first route
**popUntil(ModalRoute.withName('/cart'))**: unwinds a multi-step flow; it needs routes with names, so pass settings when you build them
**With go_router**: context.go('/home') rebuilds the stack from the location, so login is already gone; better still, flip the auth state and let redirect move the user
Logging out: clear the session before you navigate, or a redirect that still sees a valid token sends the user back in
Pass data forward through the new screen's constructor, and send a result back by popping with a value: push returns a Future that completes with whatever the second screen passes to pop.
Constructor arguments: checked by the compiler, so this is the default
Named routes: arguments arrive as Object? in RouteSettings, so every read is a cast and a typo fails at runtime
**go_router**: path and query parameters for ids, extra for an object that does not belong in a URL (it is lost on a web reload)
**The result can be null: treat it as "cancelled"
Shared data**: if several unrelated screens need it, keep it in a state holder instead of passing it through routes
It prints picked: null. The Future from push always completes when the route is popped, and if nobody passed a value (system back, the iOS swipe, the app bar arrow), it completes with null.
**The real type is String?**: push<String> returns Future<String?>, and the analyzer makes you handle null
**Treat null as "cancelled"**: if (color == null) return; before you use the value
Need to tell "cancelled" from "chose nothing"? Return a small result type, for example a sealed class or an enum, instead of overloading null
MaterialApp.routes maps exact names to screens; any name it does not match goes to onGenerateRoute, where you parse the name yourself and return a Route. onUnknownRoute catches whatever is left.
**Parse with Uri.parse(settings.name!)**: then pathSegments and queryParameters work, and a query string does not break your matching
**Pass settings on**: into the MaterialPageRoute you return, or popUntil, ModalRoute.withName and analytics lose the route name
**settings.arguments is Object?: check its type and fall back to the unknown route instead of letting a bad deep link throw
Where named routes stand today**: the Flutter docs no longer recommend them for most apps: they cannot customise deep-link handling and do not support the browser forward button; go_router does this parsing for you
The context belongs to App, which sits above MaterialApp, and MaterialApp creates the Navigator inside itself. Navigator.of only searches up the tree, so from this context there is no Navigator to find.
Extract the screen into its own widget: home: const HomePage(); a page's own context is below the Navigator, which is why real apps rarely hit this
**Or wrap the button in a Builder**: it hands you a context one level deeper, inside MaterialApp
**Or use a GlobalKey<NavigatorState>**: passed to MaterialApp.navigatorKey, for code that has no widget context at all
The nested twin: inside a tab or a ShellRoute, Navigator.of finds that Navigator; use rootNavigator: true (or useRootNavigator: true on showDialog) to reach the outer one
All three push a modal route onto a Navigator, so what looks like an overlay is a real entry on the stack: back closes it first, and it returns whatever you pass to Navigator.pop.
**showDialog**: pushes a DialogRoute; barrierDismissible: false blocks closing by a tap outside
**showModalBottomSheet**: a ModalBottomSheetRoute; isScrollControlled: true lets it grow past half the screen, which a sheet with a text field needs
**showMenu**: a popup route anchored to a position; PopupMenuButton calls it for you
**useRootNavigator: dialogs default to the root Navigator, bottom sheets do not; inside a bottom-nav tab with its own Navigator a sheet otherwise opens under the tab bar
Common mistake**: using context after the await; check context.mounted first, because the page may be gone by then
Wrap the screen in PopScope with canPop: false while there are unsaved changes; the pop is then refused, onPopInvokedWithResult runs with didPop == false, and you ask the user and pop yourself if they confirm.
Why it is split this way: canPop is a plain bool known in advance, so Android's predictive back gesture can show the right preview; the old WillPopScope (deprecated since Flutter 3.12) answered with a Future, which that gesture cannot wait for
**onPopInvokedWithResult**: replaced onPopInvoked in Flutter 3.24; it fires after the decision, so didPop == true means the route is already gone
What it catches: Android back (including predictive back), the browser back button and maybePop, which the app bar arrow calls; on iOS with canPop: false the edge swipe is disabled outright and the callback does not fire, so also offer a visible Save/Discard control. It does not catch your own Navigator.pop or context.go
Common mistake: leaving canPop: true and showing the dialog in the callback: the screen has already closed when the dialog appears
Other uses: closing a search field instead of the screen, saving a draft on the way out
Navigator 1.0 is imperative: you call push and pop. Navigator 2.0 is declarative: you give the Navigator a list of pages built from app state, and the Router keeps it in sync with the URL. It exists because deep links, web URLs and rebuilding a stack were painful imperatively.
Pages, not calls: change the state, rebuild, and the Navigator diffs the new pages list against the old one, pushing and removing routes for you
Keys matter: pages are matched by type and key; give each one a stable ValueKey, or state follows position when you insert a page
Pops come back to you: the system can still pop a page, so handle onDidRemovePage (it replaced onPopPage in Flutter 3.24) and remove that page from your state, or the next rebuild puts it back
The Router pieces: RouteInformationParser turns a URL into your state, RouterDelegate builds the Navigator from it; writing them by hand is verbose, which is why apps use go_router
Both coexist: even with go_router you still call Navigator.push for a one-off dialog or picker
go replaces the whole stack with the one your route table implies for that location; push puts one page on top of what is already there.
**go**: the location changes and the stack is rebuilt from the route tree, so back goes up the tree, not to where the user came from. Use it for top-level destinations, tab switches and deep links
**push**: adds a page and returns a Future that completes with context.pop(result), so back returns exactly where the user was. Use it for details, editors and pickers
Common mistake: go to a detail screen inside a bottom-nav tab throws that tab's history away; push for a tab switch stacks tabs on top of each other
Nearby: pushReplacement swaps the top page, replace swaps it but keeps the page key; extra carries objects a URL cannot, and is lost on a web reload
Give each tab its own Navigator and keep all tabs alive: with go_router that is StatefulShellRoute.indexedStack, which keeps one branch per tab and switches between them without disposing anything.
**IndexedStack: the plain-Flutter answer: all tabs stay in the tree and only the selected one is painted; tab state survives, but there is no per-tab history
Per-tab history**: a nested Navigator per tab, so pushing a detail in Search does not cover Home; StatefulShellRoute builds those Navigators for you and deep links still land in the right tab
**goBranch(i, initialLocation: ...)**: switches tabs; passing true when the active tab is tapped again pops it back to its root, which users expect
Common mistake: a plain ShellRoute has one Navigator, so switching tabs with go throws the previous tab's stack away
Full-screen pages over the bar: give the route parentNavigatorKey: rootNavigatorKey
The buttons pop with the page's context, not the dialog's. showDialog pushes onto the root Navigator, but inside a tab the page's nearest Navigator is the tab's own, so pop removes OrderPage and the dialog stays on screen.
Why it worked before: with one Navigator both contexts find the same one, and its top route is the dialog, so the wrong context closed the right route by accident
Two Navigators now: showDialog defaults to useRootNavigator: true, while Navigator.pop(context) looks up from the page and finds the tab's Navigator, whose top route is OrderPage
Side effects: OrderPage is popped with false as its result, and the Future from showDialog does not complete until the dialog is closed some other way, so the caller's await hangs
The fix: name the builder's parameter (dialogContext) and pop with it: a context from inside a route always finds the Navigator that holds that route; the rule is the same for sheets and menus: pop with the builder's context (a sheet defaults to the nearest Navigator, so the bug appears only with useRootNavigator: true)
Catch it: do not discard the builder's context as _ in dialog and sheet builders; a widget test with a nested Navigator taps No and expects the dialog gone and the page still there
Use redirect: go_router calls it before showing any location, and you return a different path to send the user elsewhere or null to let them through. Pass your auth state as refreshListenable so the redirect runs again on every login and logout.
Top-level for the global rule: signed out means /login; a redirect on a single GoRoute handles finer rules such as an admin area
Check where you already are: return null when the user is already on /login; this redirect appends ?from=..., so without the check each pass builds a new, longer location and older go_router versions stop at redirectLimit
Keep the destination: put the original location in a query parameter so a deep link to /cart resumes after login
Unknown yet: while the token is still loading from storage, keep everyone on a splash route instead of flashing the login screen
Common mistake: checking auth in a page's build: the protected screen is already built and in the stack
A new GoRouter is created inside build, so every rebuild of App (here a theme change) hands MaterialApp.router a fresh router that starts at its initial location, and the whole navigation stack is lost.
Why it happens: Router sees a new routerConfig, reads the route information from the new router and builds the stack for /; the pushed /settings page belonged to the old router
Any rebuild above it triggers this: theme, locale, an auth notifier, a provider above MaterialApp; it shows up only when such a value changes, so a quick manual test misses it
It also leaks: the old router is never disposed, so its listeners, including the one on refreshListenable, stay attached
The fix: create the router once: a top-level final, a field of a State (created in initState, disposed in dispose) or a DI singleton; auth changes reach it through refreshListenable, not through a new router
Catch it: a widget test that pushes a route, changes the theme and expects the page to still be on screen; in review, GoRouter( inside a build method is a red flag
The stack is [Home, A, B] and B is on top: pushReplacement swapped C for D, so the final pop removed D and uncovered B. The next Back shows A.
Step by step: [Home] → [Home, A] → [Home, A, B] → [Home, A, B, C] → [Home, A, B, D] → [Home, A, B]
**pushReplacement**: pushes the new route and removes the one beneath it, so the stack does not grow; C is gone for good
**pop removes the top**: here that is D, not C; a common wrong answer is [Home, A, B, C]
Related calls: pushAndRemoveUntil clears down to a condition (login flow), popUntil unwinds to a route without pushing
Check it: NavigatorObserver.didPush, didReplace and didPop log every step if you doubt the result
Use verified https links: host assetlinks.json and apple-app-site-association under /.well-known/ on your domain, declare the domain in the app, and let MaterialApp.router receive the path; Flutter passes deep links to the router by default since 3.27.
**Custom scheme (myapp://product/42): easy, but any app can claim the same scheme and it means nothing in a browser
App Links / Universal Links: https URLs open the app when it is installed and the website when it is not; the OS checks your hosted file to prove you own the domain, so nobody can hijack them
Android**: an intent filter with android:autoVerify="true" and the release signing key's SHA-256 in assetlinks.json; check with adb shell pm get-app-links <package>
iOS: the Associated Domains entitlement applinks:example.com; Apple fetches the file through its CDN, so changes can take hours to reach devices
Your own handler: with app_links or a vendor SDK, turn Flutter's handling off (flutter_deeplinking_enabled / FlutterDeepLinkingEnabled = false) so a link is not handled twice
Test: adb shell am start -a android.intent.action.VIEW -d <url> and xcrun simctl openurl booted <url>, both cold and warm
Navigate through an object that lives outside the widget tree: a GlobalKey<NavigatorState> passed to MaterialApp.navigatorKey, or with go_router the GoRouter instance itself, kept in your DI container.
The key: navigatorKey.currentState?.push(...) works from a service; currentContext gives a context for a dialog
**go_router**: the router is the API: router.go('/orders/42') or router.push(...) from the notification handler
Timing breaks it: a tap can arrive before the first frame; currentState is still null and ?. silently drops the call. Store the pending location and use it once the app is up
Two cases in Firebase Messaging: getInitialMessage() for the tap that launched the app (use it as initialLocation), onMessageOpenedApp for a tap while the app was in the background
Cleaner for most events: publish the event to your state layer and let a widget that has a context react to it
Flutter web uses the hash strategy by default: everything after # is never sent to the server, so any static host works. Clean paths (usePathUrlStrategy()) send the full path to the server, which must answer unknown paths with index.html.
Turning it on: call usePathUrlStrategy() from package:flutter_web_plugins/url_strategy.dart before runApp; it needs flutter_web_plugins from the SDK in pubspec.yaml, <base href="/"> must stay in web/index.html, and it does nothing on mobile
Then configure the host: a rewrite of ** to /index.html on Firebase Hosting, try_files $uri $uri/ /index.html on nginx; a host without rewrites is a reason to stay on hash URLs
**extra is not in the URL: a refresh or a shared link loses it, so anything a page needs to rebuild itself goes into path or query parameters
The test**: paste the URL into a fresh tab: you must land on the same screen with a sensible stack under it
Use PageRouteBuilder (or CustomTransitionPage in go_router) and wrap the page in your own transition inside transitionsBuilder; animation drives this route in and out, secondaryAnimation drives it while another route is pushed on top of it.
**pageBuilder**: returns the destination widget; transitionsBuilder(context, animation, secondaryAnimation, child) wraps it
**animation: 0 to 1 on push, 1 to 0 on pop
secondaryAnimation: 0 to 1 when the next route covers this one; use it to move or dim the old screen
Curves and timing**: run the 0..1 values through animation.drive(CurveTween(curve: ...)); transitionDuration and reverseTransitionDuration are separate
App-wide instead of per route: set pageTransitionsTheme in ThemeData; the default MaterialPageRoute already uses the platform's look
Wrap a widget in Hero with the **same tag on both screens; during the page transition Flutter lifts it into the overlay and animates it from its old position and size to the new one.
Tags must be unique per screen**: two Heroes with one tag on a route throw "There are multiple heroes that share the same tag within a subtree", so lists use 'product-${product.id}'
Only between page routes: heroes fly for PageRoute transitions; a dialog or a bottom sheet does not trigger them
Text turns red with yellow underlines mid-flight: the flying widget has no Material ancestor; wrap it in Material(type: MaterialType.transparency)
Different shapes: a flightShuttleBuilder or createRectTween smooths a change of aspect ratio or a round-to-square image
Nested Navigators: a Navigator you create yourself has no HeroController, so wrap it in HeroControllerScope
Use a RouteObserver and mix RouteAware into the screen's State: didPushNext fires when a route covers this one, didPopNext when it is visible again. The covered screen is not disposed, so without this its video keeps playing.
The screen below stays alive: it keeps its State, timers and streams; Flutter only stops painting it and turns off its tickers, so AnimationControllers pause, but a video player does not; only an opaque route actually hides the page
Only full pages count: use RouteObserver<PageRoute<void>>; with ModalRoute a dialog or sheet also triggers didPushNext, although the page stays visible and the video should keep playing
Setup: add the observer to MaterialApp.navigatorObservers (or GoRouter(observers: ...); a nested ShellRoute Navigator needs its own) and subscribe in didChangeDependencies
Common mistake: waiting for didChangeAppLifecycleState: the app is still resumed, only the route changed
**maintainState: false**: on MaterialPageRoute throws the covered page away entirely; its local state is lost on the way back
Guard the navigation yourself: the Navigator never deduplicates, because two routes built from the same widget really are two routes. A flag that blocks a second tap until the first navigation finishes is enough.
Why it happens: both taps land before the first transition starts, and an await before the push widens the window to a whole network request
A flag in the State: _navigating = true before the work, reset in finally, so an error does not lock the list
**isCurrent is not enough: it stays true until the first route is pushed, so it cannot stop two taps that arrive before the transition starts; keep the flag
Disable the control**: onPressed: _busy ? null : _open is the same guard, and the user sees why nothing happens
At scale: put the guard in one navigation helper instead of every widget, so screens added later get it too
Make the URL describe the hierarchy and let the router build every page above the target: in go_router, nest the product route under the catalog route, so /catalog/product/42 produces [Home, Catalog, Product] and Back walks up instead of out.
Building the stack
**go_router**: go builds one page per matched route from root to leaf; a top-level /product/:id builds a stack of one
Navigator 1.0: initialRoute: '/catalog/product/42' makes the default onGenerateInitialRoutes push /, /catalog and the full path, if all of them are in routes
Tabs: with StatefulShellRoute, put the route in the branch it belongs to, so the link also selects the right tab
When the parent is not in the URL
A detail reachable from several places: pick one canonical parent for links; the in-app flow can still push it from anywhere
The parent needs data: let it load from its own path parameters, so a cold link builds it without the previous screen's state
Cold vs warm
Cold start: the link becomes the initial location, and the stack is built from it
App already open: go replaces whatever the user had open; for a notification tap you often want push on top of the current stack instead. Decide per link type and test both
Turn on Flutter's state restoration: give the app a restorationScopeId and push with the restorable* Navigator methods, so the framework saves the route stack with the OS and rebuilds it after the process is killed.
**MaterialApp(restorationScopeId: 'app'): nothing is restored without a root restoration scope, and this is the line most often missing
Navigator.restorablePush(context, _orderRoute, arguments: id)**: _orderRoute must be a static or top-level function annotated with @pragma('vm:entry-point'): what is saved is a reference to it plus its arguments, so a closure cannot be restored and arguments must be simple values (anything StandardMessageCodec can encode)
Widget state: RestorationMixin with Restorable* values; a ListView with a restorationId restores its scroll offset
**With go_router**: the location is the state: MaterialApp.router(restorationScopeId: ...) plus GoRouter(restorationScopeId: ...); extra is not restored
Keep it small: the saved data has a size limit, so store ids and reload the rest
Test it: enable Don't keep activities in developer options, or background the app and run adb shell am kill <package>