Flutter navigation has a reputation, and it is earned. You start with Navigator.push, everything is fine, and then you need a deep link, or a back button that behaves on the web, or a bottom navigation bar where each tab remembers where you left it. Suddenly you are reading about Navigator 2.0, RouterDelegate, and RouteInformationParser, and none of it makes sense.
go_router is the official answer to that, maintained by the Flutter team. It is genuinely good. The problem is that most explanations of it either stop at the two-screen hello world or dump the entire API on you at once, so people come away knowing the syntax without the mental model.
Here is the version I wish I had when I started: what it actually does, the handful of gotchas that trip everyone up, and the patterns I reuse in every app.
Where Navigator 1.0 and 2.0 fit in
Worth clearing this up early, because it is the thing people think they need to study first.
Navigator 1.0 is the API you already know. Navigator.push, Navigator.pop, a stack of routes you manipulate one instruction at a time. It is not deprecated and it is not going anywhere.
What it cannot do well is answer the question "given this URL, what should be on screen right now". A push is a command, not a description. Nothing in your code says what the stack should look like, so there is no way to rebuild it from an incoming link, and no way to tell a browser what belongs in the address bar.
Navigator 2.0, these days more often called the Router API, was added to fix exactly that. It is declarative: you hand Flutter a list of pages derived from your app state and it works out the rest. The model is right, but the raw API is rough. You implement a RouterDelegate, a RouteInformationParser, usually a BackButtonDispatcher, and you write a few hundred lines of boilerplate before a single screen shows up. That is where most people bounce off.
go_router is the Flutter team's own wrapper over that machinery. You describe your routes, and it implements the delegate and the parser for you.
So the practical takeaway is this: you do not need to learn Navigator 2.0 to use go_router. Skipping it is the entire point of the package. And Navigator 1.0 is not gone either. go_router still builds a Navigator underneath, push and pop still mean what you expect, and inside a screen you can still reach for showDialog or a plain Navigator.push when something genuinely does not deserve a URL.
The one idea that makes it click
go_router turns your app into a set of URLs.
That is the whole thing. Instead of thinking "push this widget onto a stack", you think "the app is at /family/f1/person/p2". You describe what each URL looks like, and go_router figures out which screens to build and how to stack them.
This is why it handles deep links and browser back buttons for free. A deep link is just someone starting your app at a URL, and the back button is just moving to a shorter URL. Once your navigation is URL-shaped, those stop being special cases.
The smallest version that works:
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
),
GoRoute(
path: '/settings',
builder: (BuildContext context, GoRouterState state) => const SettingsScreen(),
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(routerConfig: _router);
}
}Two things to notice. You use MaterialApp.router instead of MaterialApp, and you hand it routerConfig. That is the entire wiring. Everything else is describing routes.
Then you navigate:
context.go('/settings');Nested routes, and the slash that breaks everything
Real apps have hierarchy. A family has people, a post has comments. You express that by nesting routes inside routes:
GoRoute(
path: '/family',
builder: (BuildContext context, GoRouterState state) => const FamilyScreen(),
routes: <RouteBase>[
GoRoute(
path: 'details',
builder: (BuildContext context, GoRouterState state) => const DetailsScreen(),
),
],
),That child route is at /family/details. Paths concatenate as you nest.
Here is the gotcha that gets nearly everyone: child route paths must not start with a slash. It is 'details', not '/details'. A leading slash means "this is a top-level, absolute path", and nesting an absolute path where a relative one belongs will fail an assertion. When someone says their nested routes "just don't work", this is usually why.
Nesting is not only about tidy URLs. It also decides what the back button does, which matters in the next section.
go versus push, the distinction everyone gets wrong
You get two ways to navigate and they are not interchangeable.
context.go() is declarative. You are saying "the app is now at this location", and go_router rebuilds the page stack from your route tree to match. If you go to /family/details, the back button takes you to /family, because that is the parent in the tree, regardless of which screen you came from.
context.push() is imperative. It puts a page on top of whatever is already there, exactly like the old Navigator.push. The back button returns you to where you actually came from, and you can get a value back:
final bool? saved = await context.push<bool>('/post/edit');
// and inside the edit screen
context.pop(true);The rule I follow: use go for primary navigation, the places a user could legitimately land from a deep link. Use push for flows stacked on top of the current context, like an edit sheet or a confirmation, especially when you want a result back.
Getting this wrong produces the classic bug where the back button sends someone to a screen they were never on. That is almost always a go that should have been a push.
Passing data: three options, and when each is right
Path parameters are for identity. Declare them with a colon, read them from state.pathParameters:
GoRoute(
path: 'family/:fid',
builder: (BuildContext context, GoRouterState state) {
return FamilyScreen(fid: state.pathParameters['fid']!);
},
),Query parameters are for options that modify a view, like sorting or filtering. They live on state.uri:
GoRoute(
path: 'family/:fid',
builder: (BuildContext context, GoRouterState state) {
return FamilyScreen(
fid: state.pathParameters['fid']!,
asc: state.uri.queryParameters['sort'] == 'asc',
);
},
),Note it is state.uri.queryParameters, not state.queryParameters. That trips people up when following older tutorials.
Extra passes a real Dart object without putting it in the URL:
context.go('/family/f1', extra: myFamilyObject);
// then
final Family family = state.extra! as Family;extra is convenient and it is a trap if you lean on it. It is not part of the URL, so it does not survive a deep link, a page refresh on web, or a restart. If a screen cannot render without the object you passed in extra, then that screen is not deep-linkable. Pass an id in the path and load the object from that id. Use extra only as an optimization, never as the only source of truth.
Named routes so you stop hardcoding strings
Sprinkling '/family/${id}/person/${pid}' through your codebase means one URL change breaks things in twenty places. Give routes a name instead:
GoRoute(
name: 'person',
path: 'person/:pid',
builder: (BuildContext context, GoRouterState state) {
return PersonScreen(
fid: state.pathParameters['fid']!,
pid: state.pathParameters['pid']!,
);
},
),Then navigate by name and let go_router build the URL:
context.goNamed('person', pathParameters: <String, String>{
'fid': 'f1',
'pid': 'p2',
});Now the path lives in exactly one place. I skip this on throwaway projects and regret it on anything I maintain.
Guarding routes with redirect
This is where go_router really pays off. Instead of checking auth inside every screen, you check it once, centrally:
final GoRouter _router = GoRouter(
routes: <RouteBase>[ /* ... */ ],
redirect: (BuildContext context, GoRouterState state) {
final bool loggedIn = _loginInfo.loggedIn;
final bool loggingIn = state.matchedLocation == '/login';
if (!loggedIn) {
return loggingIn ? null : '/login';
}
if (loggingIn) {
return '/';
}
return null;
},
refreshListenable: _loginInfo,
);Return a path string to redirect, or null to let the navigation through. Two details make this work properly:
The loggingIn check prevents an infinite loop. Without it, an unauthenticated user heading to /login gets redirected to /login, forever. go_router will stop you at five redirects and show an error screen, which is a good hint that you have a loop.
refreshListenable takes a Listenable (a ChangeNotifier works). When it notifies, the router re-runs the redirect. That is what makes logging out from a settings screen bounce the user to the login page automatically, without any navigation code in your logout button.
A refinement worth adding: remember where they were going so you can send them back after login.
if (!loggedIn) {
return loggingIn ? null : '/login?from=${state.uri}';
}Bottom navigation that keeps its state
This is the feature people struggle with most, and it is the one that justifies go_router on its own.
You want a bottom nav bar where each tab has its own navigation stack. Open a detail screen in the Home tab, switch to Profile, come back to Home, and the detail screen should still be there. A plain ShellRoute will not do that, because it shares one Navigator across tabs.
StatefulShellRoute.indexedStack gives each branch its own Navigator:
StatefulShellRoute.indexedStack(
builder: (
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return ScaffoldWithNavBar(navigationShell: navigationShell);
},
branches: <StatefulShellBranch>[
StatefulShellBranch(
routes: <RouteBase>[
GoRoute(
path: '/home',
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
routes: <RouteBase>[
GoRoute(
path: 'details',
builder: (BuildContext context, GoRouterState state) =>
const DetailsScreen(),
),
],
),
],
),
StatefulShellBranch(
routes: <RouteBase>[
GoRoute(
path: '/profile',
builder: (BuildContext context, GoRouterState state) =>
const ProfileScreen(),
),
],
),
],
),The shell widget is refreshingly boring. The navigationShell is itself a widget, so it goes straight into the body:
class ScaffoldWithNavBar extends StatelessWidget {
const ScaffoldWithNavBar({required this.navigationShell, super.key});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
currentIndex: navigationShell.currentIndex,
onTap: (int index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
),
);
}
}Use goBranch rather than go to switch tabs. That is what restores each branch's last navigation state instead of resetting it. The initialLocation argument handles the familiar behavior where tapping the tab you are already on pops you back to that tab's root.
Because /home/details is nested inside the branch, opening it covers the screen but leaves the bottom bar visible. That is the layout everyone wants and struggles to hand-roll.
When it does not work
Two things make debugging much faster.
Turn on logging and go_router will tell you what it matched and every redirect it followed:
GoRouter(
debugLogDiagnostics: true,
routes: <RouteBase>[ /* ... */ ],
);And handle unmatched routes yourself, so a typo shows something useful instead of a bare red screen:
GoRouter(
routes: <RouteBase>[ /* ... */ ],
errorBuilder: (BuildContext context, GoRouterState state) =>
ErrorScreen(error: state.error),
);Most of the failures I have hit come down to the same short list: a leading slash on a nested path, go where push belonged, reading state.queryParameters instead of state.uri.queryParameters, a redirect loop from a missing "am I already there" check, or a screen that cannot survive a deep link because it depends on extra.
None of that is really about go_router. It is about the shift from thinking in stacks to thinking in URLs. Once your navigation is a set of locations rather than a pile of pushes, deep links, the web back button, and stateful tabs stop being three separate problems you solve three separate ways. They are just consequences of getting the routes right, and that is the part worth spending your time on.
Cover photo by Anne Rosly on Unsplash

