Flutter GoRouter Tutorial: Stop Fighting Navigation and Start Using URLs (2026)

You built a Flutter app. It navigates. Then a user taps a notification, the app opens to the wrong screen. Someone sends a product link, the app just opens the home page. Your back button pops two screens instead of one. You add a bottom navigation bar and suddenly each tab forgets where it was.

All of these are the same problem: Navigator.push() has no concept of URLs. Every screen is a disposable widget on a stack with no address, no identity, and no way for the outside world to reach it. The moment your app gets even slightly complex — deep links, auth flows, web URLs, persistent tabs — it falls apart.

GoRouter fixes all of this. It gives every screen in your Flutter app a real URL path. Navigation becomes declaring where you want to go, not manually stacking and popping widgets. Deep links just work. Auth guards live in one place. The back button behaves. And Flutter’s own team officially recommends it for any app that needs reliable navigation.

This flutter gorouter tutorial starts from zero and builds up to a complete app with named routes, parameter passing, auth guards, a persistent bottom navigation bar, and deep linking — all explained the way a beginner actually needs it.

📋 Prerequisites

1. Why GoRouter? The Real Reason to Switch

Plain Navigator.push() works well for simple apps. Here’s exactly where it breaks down — and why each failure points to the same root cause:

The problem Why Navigator fails GoRouter fix
Deep links open the wrong screenNavigator has no URL concept — can’t map an incoming URL to a screenEvery GoRoute has a URL path — incoming URLs auto-match
Web URL bar doesn’t updateNavigator manages a widget stack, not URLsBrowser URL updates automatically on every navigation
Auth guard logic is scattered everywhereEach screen checks auth independently — inconsistentOne global redirect function handles all auth
Context errors in async flowsNavigator.push(context, ...) needs a valid context that may be gone after asyncGoRouter is accessed through the router config, not context lifecycle
Bottom nav tabs forget their scroll positionSwitching tabs recreates widget trees — state is lostStatefulShellRoute keeps each tab alive independently

The reason Navigator has all these problems is that it is an imperative widget stack — screens have no names, no URLs, no addresses. GoRouter is declarative and URL-based. Every route is a path. That single change fixes all of the above at once.

💡 Will my existing Navigator.push() code break?
No. Navigator 1.0 is never being deprecated — Flutter’s team confirmed this. GoRouter is an abstraction on top of Navigator 2.0 that lives alongside it. You can migrate screen by screen, and GoRouter’s context.push() coexists with any remaining Navigator.push() calls during transition. That said, mixing the two after migrating causes stack corruption — commit to one approach per app.

2. Setup and Installation

The key setup change: replace MaterialApp with MaterialApp.router. This is the most important conceptual shift when coming from Navigator.push():

3. Your First GoRouter: Define Routes and Navigate

Define the router as a top-level final variable — never inside a widget’s build() method. Creating a new router on every rebuild wipes navigation state entirely:

4. Named Routes — Navigate Without String Literals

Assigning a name to each route lets you navigate by name instead of path string. This eliminates typos, is refactor-safe (rename the path without touching call sites), and enables passing typed parameters:

5. go() vs push() vs pop() — The Complete Breakdown

This is the most misunderstood part of GoRouter. Getting it wrong means your back button vanishes or your navigation history behaves unexpectedly:

Method What it does to the stack Back button Use for
context.go('/path')Replaces the entire stackNo back — nothing behind itTop-level navigation between app sections
context.push('/path')Adds on top of current stackYes — returns to previous screenDrill-down detail screens
context.pop()Removes current screen from stackReturns to whatever was belowDismissing a pushed screen
context.replace('/path')Replaces only the current screenYes — goes back to what was belowLogin → Home (don’t allow back to login)
context.canPop()Returns bool — doesn’t navigateN/ACheck before popping to avoid underflow
⚠️ Flutter Web: push() doesn’t update the URL bar by default
On Flutter Web, context.push() does not update the browser URL by default. Fix it by adding this line in main() before building the router:

GoRouter.optionURLReflectsImperativeAPIs = true;

6. Passing Parameters: Path, Query, and Extra

GoRouter has three ways to pass data between routes. Each has different trade-offs — especially around deep linking:

Method 1: Path Parameters (embedded in the URL)

Method 2: Query Parameters (after the ? in the URL)

Method 3: extra (Dart objects — not URL-safe)

7. Nested (Sub) Routes

8. Redirects and Auth Guards

Redirects are GoRouter’s most powerful feature for production apps. Instead of checking auth in every screen’s initState, you define one redirect function that runs before every navigation event:

Global redirect (app-wide auth guard)

🚨 Always guard against redirecting FROM the login page
If your redirect function says “if not logged in, go to /login” but doesn’t check that the user isn’t already on /login, navigating to /login triggers the redirect which sends to /login which triggers the redirect… infinite loop crash. Always check state.matchedLocation != '/login' before redirecting to login.

Per-route redirect (role-based access)

Reactive redirect with refreshListenable (the right way to handle logout)

9. Error Handling — Custom 404 Page

10. StatefulShellRoute: Persistent Bottom Navigation Bar

StatefulShellRoute.indexedStack is how GoRouter handles a bottom navigation bar where each tab keeps its state (scroll position, sub-navigation stack) when you switch between them. Without it, switching tabs destroys and recreates the previous tab completely.

💡 What initialLocation: index == navigationShell.currentIndex does
When a user taps the Home tab while already on the Home tab (having navigated to an article), initialLocation: true makes GoRouter pop all the sub-routes and return to the tab’s root screen — exactly like Instagram’s tab behavior. Setting it to false instead would do nothing on a tap of the current tab.

11. Deep Linking Setup (Android and iOS)

GoRouter is URL-based by design — every route already has a path. To open those paths from outside the app (notifications, web links, emails), you need two things: platform-level config to intercept the URL, and GoRouter automatically maps it to the right screen.

Android (AndroidManifest.xml)

iOS (Info.plist)

After platform setup, GoRouter handles the rest automatically. An incoming URL like https://yourapp.com/products/42 is parsed, matched against your route tree, and the correct screen opens with productId = '42' — zero extra Flutter code needed.

💡 Deep links + auth redirects work together automatically
If a deep link arrives for /profile and the user isn’t logged in, GoRouter’s global redirect fires first — the user is sent to /login. Save the intended destination with state.uri.toString() in your redirect and redirect to it after successful login.

12. Common Mistakes

Mistake What happens Fix
Using MaterialApp instead of MaterialApp.routerGoRouter is ignored entirely — app shows nothing or the wrong screenReplace with MaterialApp.router(routerConfig: _router)
Defining GoRouter inside build()New router created on every rebuild — navigation state wipedDefine as final top-level variable outside any widget
Child route path starting with /GoRouter throws RouteException at startupChild paths are relative — no leading slash: path: ':id' not path: '/:id'
Using go() for detail screens that need a back buttonBack button disappears — go() replaces the whole stackUse push() for drill-down navigation where back is expected
Not guarding the login page in the redirectInfinite redirect loop — app crashesAlways check state.matchedLocation != '/login' before redirecting to /login
Using extra for deep-linkable dataextra is null when screen is opened via deep linkUse path or query params for any data that must survive deep linking
Mixing Navigator.push() with context.go()Stack corruption — unpredictable back button behaviorAfter migrating to GoRouter, use only GoRouter methods for navigation
Sharing GlobalKey across StatefulShellBranchesTab navigation state merges — wrong tabs show wrong contentEvery branch needs its own unique GlobalKey<NavigatorState>
Not setting optionURLReflectsImperativeAPIs on webBrowser URL bar doesn’t update when using push()GoRouter.optionURLReflectsImperativeAPIs = true in main()

13. Interview Q&A

Q: What is GoRouter and why does Flutter recommend it?

A: GoRouter is a routing package published by the Flutter team (flutter.dev) that sits on top of Navigator 2.0 and provides URL-based declarative routing. Flutter recommends it because it solves Navigator 1.0’s main weaknesses: no deep link support, no URL bar updates on web, scattered auth logic, and complex back-button behavior with nested navigators. GoRouter wraps all of Navigator 2.0’s complexity (RouterDelegate, RouteInformationParser) behind a simple API.
Q: What is the difference between context.go() and context.push() in GoRouter?

A: context.go() replaces the entire navigation stack — like typing a new URL in a browser. The user cannot go back. Use it for top-level navigation between app sections. context.push() adds the new screen on top of the current stack — the back button is visible and returns to the previous screen. Use it for drill-down navigation like opening a detail screen from a list. The most common beginner mistake is using go() for a detail screen and then wondering why the back button disappeared.
Q: How do you implement an auth guard in GoRouter?

A: Use the redirect parameter on GoRouter. It fires before every navigation event. Return a path string to redirect, or null to allow. For reactive redirects that fire when auth state changes mid-session (e.g. session expiry), use refreshListenable with a ChangeNotifier that calls notifyListeners() on login and logout. This eliminates scattered context.go('/login') calls throughout your app — the router handles it automatically.
Q: What is StatefulShellRoute and when do you need it?

A: StatefulShellRoute.indexedStack is how GoRouter builds a persistent bottom navigation bar where each tab maintains independent state. Without it, switching tabs destroys and recreates the tab’s widget tree — scroll position and sub-navigation are lost. With it, each branch has its own GlobalKey<NavigatorState> and its own Navigator stack, so tab state is preserved. You need it for any app with a bottom navigation bar where users should be able to navigate within a tab and return to where they were after switching tabs.
Q: What are the three ways to pass data between routes in GoRouter?

A: Path parameters (embedded in the URL as :paramName — required, always non-null when matched), query parameters (appended as ?key=value — optional, nullable), and extra (any Dart object passed outside the URL). The critical distinction: path and query params survive deep links and page refreshes because they are encoded in the URL. extra is lost when a screen is opened via deep link — it cannot be reconstructed from a URL. Use extra only for in-app navigation where you already have the object in memory.
Post Why it’s relevant
Flutter Navigation: Push, Pop, and Named RoutesThe prerequisite — understand what Navigator does before learning how GoRouter replaces it.
Flutter Bottom Navigation BarThe StatefulShellRoute section directly extends this — add GoRouter to the nav bar patterns covered there.
Flutter Widgets: Stateless vs StatefulThe AuthNotifier pattern in the redirect section uses ChangeNotifier — this post covers the foundational concepts.
Flutter SharedPreferences TutorialThe auth guard pattern here pairs naturally with storing login state in SharedPreferences — see how both fit together.
Understanding pubspec.yamlAdding go_router requires editing pubspec.yaml — this guide covers every aspect of that file.
Show 1 Comment

1 Comment

Leave a Reply