Flutter Development

Flutter Navigation with go_router: A Practical Guide

Learn how to set up declarative navigation in Flutter with go_router — nested routes, deep linking, redirects, and passing data between screens — with working code and the mistakes to avoid.

July 19, 202610 min readDeval Joshi
Fluttergo_routerNavigationDeep LinkingRouting

Flutter Navigation with go_router: A Practical Guide

Every Flutter app I’ve built started the same way: a couple of Navigator.push(context, MaterialPageRoute(...)) calls scattered around, and it worked fine. Then the app grew a bottom nav bar, a login redirect, a “share this product” deep link, and suddenly the navigation code was the messiest part of the codebase — nested Navigator widgets, context passed six layers deep just to push a screen, and no clean way to say “if the user isn’t logged in, send them to /login no matter what URL they typed.”

go_router is the package Flutter’s own team recommends for this, and it solves those problems by treating routes as URLs with a defined tree, not as a stack you push and pop imperatively. Once it clicks, it’s genuinely less code, not more — but the mental model is different enough that it trips people up on day one. This is the guide I wish I’d had.

Why bother switching from Navigator 1.0

If your app is a handful of screens with no deep linking and no auth-gated routes, plain Navigator.push is fine — don’t add a dependency you don’t need. Reach for go_router when you hit any of these:

  • You need deep links or web URLs that map to a specific screen (/product/42 should open product 42, whether the user tapped it in-app or opened it from a browser or push notification).
  • You have a bottom navigation bar or tabs where each tab keeps its own back stack.
  • You need route guards — redirecting to /login if the user isn’t authenticated, regardless of which URL they tried to reach.
  • You’re shipping to Flutter Web and want the browser’s back/forward buttons and address bar to actually work.

Setup

The core idea: you define a GoRouter with a list of GoRoutes, each mapped to a path and a builder. You hand that router to MaterialApp.router instead of using MaterialApp directly.

To navigate, you no longer build a MaterialPageRoute by hand:

go and push look similar but behave differently, and this is the first thing that confuses people coming from Navigator. Use push when you want a normal “back button returns to where I was” screen (e.g. opening a product detail from a list). Use go when you’re navigating between top-level destinations where you don’t want the old screen sitting in the stack (e.g. switching bottom-nav tabs, or redirecting after login).

Passing data between screens

You’ve got two real options, and the difference matters.

Path/query parameters — good for anything that should survive a deep link or a page refresh (an ID, a filter, a search term):

extra — for passing an actual Dart object (a full model you already have in memory, so you don’t have to refetch it):

The catch with extra: it doesn’t survive a deep link or a browser refresh, because there’s no object to decode from a URL — it only exists if you navigated to it from within the running app. If a screen needs to work as a shareable link, its data has to come from the path, not extra.

Nested navigation with a bottom nav bar (ShellRoute)

This is the part that used to require a IndexedStack and manual state juggling. go_router’s StatefulShellRoute gives each tab its own independent navigation stack, so switching tabs and coming back preserves where you were.

Each branch keeps its own history. If you push three screens deep into “Orders”, switch to “Profile”, then switch back to “Orders”, you land back on the third screen — not the tab root. That’s the behavior most apps actually want and it’s tedious to hand-roll with plain Navigator.

Redirects (auth gating)

This is the feature that made me switch in the first place. Instead of checking auth state inside every screen’s initState, you check it once, centrally:

refreshListenable is the part people miss. Without it, the redirect logic only runs when navigation happens — so if the user’s session expires in the background, the router won’t know to boot them to /login until they happen to navigate somewhere. Wire it to a ChangeNotifier (or a ValueNotifier<bool>) that flips when login state changes, and the redirect re-evaluates automatically.

Deep linking

If you’ve set up your routes with real paths (as above), deep linking mostly works out of the box on Android and iOS once you configure the platform side:

  • Android: add an intent-filter for your scheme/host in AndroidManifest.xml.
  • iOS: add associated domains capability, or a custom URL scheme in Info.plist.

The Flutter side needs nothing extra beyond your existing route definitions — go_router parses the incoming URI and matches it against your routes the same way it matches an in-app context.go() call. That symmetry (in-app navigation and external deep links going through the exact same routing table) is the whole point of the declarative approach.

Mistakes I’ve made with go_router

Using push for every navigation out of habit. It quietly builds up a deep back stack, and users end up tapping back a dozen times to get out of the app. Default to go for anything that’s a “destination,” and reserve push for genuinely hierarchical drill-downs.

Putting business logic inside builder. The builder callback runs on every rebuild triggered by the router. Fetching data or running side effects there instead of in the screen’s own initState/build causes duplicate network calls. Keep builder to “construct the widget with the params I was given,” nothing more.

Forgetting parentNavigatorKey for full-screen dialogs inside a shell. If you push a route from within a StatefulShellRoute branch and want it to cover the bottom nav bar (not render inside the shell), you need to route it through the root navigator explicitly — otherwise it renders awkwardly inside the tab’s own stack.

Frequently asked questions

Do I need go_router if my app doesn’t support deep links? Not strictly, but you’ll likely want deep links eventually (push notifications alone are a common reason), and retrofitting routing after the fact is more work than starting with it. For a genuinely tiny app (a handful of screens, no auth), plain Navigator is still the simpler choice.

Is go_router the same as Navigator 2.0? go_router is built on top of Navigator 2.0’s Router API, but it hides almost all of that API’s boilerplate (RouteInformationParser, RouterDelegate, etc.) behind a declarative route list. You get the power without writing the plumbing yourself.

Can I use go_router with GetX or Riverpod? Yes — go_router only owns navigation, not state management. It works fine alongside any state solution; you’d typically hold your GoRouter instance in whatever DI/provider setup you’re already using so redirect can read auth state from it.

How do I handle a 404 / unknown route? Pass an errorBuilder to GoRouter — it’s called whenever no route matches the current location, so you can show a proper “page not found” screen instead of a red error screen.


Working through a navigation refactor or a deep-linking bug in your Flutter app? Get in touch — happy to help.