Stop Fighting Navigator 1.0: Why go_router Is the Sensible Default for Flutter Navigation
Flutter's imperative Navigator fights you on deep links and web URLs. Here's why go_router's declarative route table, redirects, and StatefulShellRoute are the sensible default — plus the trade-offs to know first.
10 Jan 2026, 15:05 UTC

If you've ever tried to make a Flutter app respond properly to a deep link — say, opening /orders/42 from an email — using only Navigator.push and pop, you know the pain. The imperative Navigator API thinks in terms of a stack you mutate, while URLs think in terms of a location you describe. Those two models don't reconcile cleanly, and the glue code you end up writing is exactly the kind that breaks six months later.
The thesis of this post is simple: for any Flutter app that ships on the web, handles deep links, or has more than a handful of screens, go_router — the Flutter team's supported declarative routing package — should be your default. Here's why, with a working sketch you can adapt.
The problem with push and pop
Navigator 1.0 is fine for a demo. You push a route, the user taps back, you pop. But the moment you need the browser address bar to reflect app state, or an Android App Link to land the user three screens deep with a sensible back stack, the imperative model fights you. Flutter's underlying Router API (sometimes called Navigator 2.0) solves this correctly by making the navigation stack a function of app state — but using it directly means writing a RouterDelegate, a RouteInformationParser, and a lot of boilerplate most teams don't want to maintain.
go_router wraps that machinery in a route table you declare once. You get path parameters, query parameters, redirects, and error pages in one place, and because every route is URL-based, deep linking and web support come almost free.
A minimal route table with a path parameter
Add go_router to pubspec.yaml (pin the version — more on that below), then define your routes where you build the app:
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/orders/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return OrderScreen(orderId: id);
},
),
],
errorBuilder: (context, state) =>
NotFoundScreen(location: state.uri.toString()),
);
// In your app widget:
MaterialApp.router(routerConfig: router);Navigate with context.go('/orders/42') or context.push('/orders/42'). A deep link to /orders/42 hits the exact same code path, which is the whole point: one definition serves programmatic navigation, browser URLs, and platform deep links.
Centralizing auth with redirects
The feature that pays for itself fastest is the top-level redirect callback. Instead of scattering "are we logged in?" checks across screens, you intercept every navigation in one place:
redirect: (context, state) {
final loggedIn = authState.isSignedIn;
final goingToLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingToLogin) return '/login';
if (loggedIn && goingToLogin) return '/';
return null; // no redirect
},Now a deep link to /orders/42 from a signed-out user lands on the login screen automatically, whether the navigation came from a push notification, a browser URL, or a button. One caveat: this callback runs on every navigation event, so keep it cheap and synchronous. Reading a cached auth flag is fine; awaiting a network call is asking for jank and redirect loops.
Bottom navigation that keeps its state: StatefulShellRoute
A common requirement — a bottom nav bar where each tab keeps its own scroll position and back stack — is awkward with plain Navigator. StatefulShellRoute.indexedStack handles it by giving each tab its own navigator branch and preserving state when you switch. You declare one branch per tab, each with its own routes, and the shell widget renders the bar:
StatefulShellRoute.indexedStack(
builder: (context, state, shell) =>
ScaffoldWithNavBar(shell: shell),
branches: [
StatefulShellBranch(routes: [
GoRoute(path: '/feed', builder: ...),
]),
StatefulShellBranch(routes: [
GoRoute(path: '/profile', builder: ...),
]),
],
)Switching tabs calls shell.goBranch(index), and each branch's stack survives the switch. If you want compile-time safety on top of all this, the companion go_router_builder package generates typed route classes from annotations, so a typo'd path fails at build time instead of at runtime.
Trade-offs you should know before committing
- API churn. go_router's API has changed meaningfully across major versions — builder signatures and shell route APIs have moved around. Pin your version in
pubspec.yamland read the changelog before upgrading. - One mental model per app. Mixing raw
Navigator.pushwith go_router routes produces confusing back stacks, because the imperative pushes sit outside the declarative route table. Migrate fully or not at all. - Web URL strategy needs server help. With path-based URLs (
usePathUrlStrategy()), refreshing/orders/42on the web 404s unless your server rewrites all paths toindex.html. Hash URLs avoid this but look ugly and complicate some link-sharing scenarios. - Redirects can loop. A redirect that returns a location which itself redirects to the original will spin. Always include a terminal condition (like the
goingToLogincheck above).
How to verify it actually works
Don't take any of this on faith — it takes fifteen minutes to check. Create a fresh project, add go_router, and define the two routes above. Then: (1) navigate to /orders/42 in code and confirm state.pathParameters['id'] is "42"; (2) run on the web with usePathUrlStrategy() and refresh a deep URL — with proper server rewrites you should land on the right page, without them you'll see the 404 yourself; (3) add the auth redirect, sign out, and try reaching /orders/42 both via context.go and via the address bar. Both should land on /login. If all three pass, your routing layer is doing its job.
Navigation is one of those decisions that's cheap to get right at project start and expensive to retrofit. Declare your routes, centralize your redirects, pin your version — and spend your complexity budget on features instead of back-stack archaeology.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.