Flutter navigation starts simple. You push a screen, you pop back, and everything works — until the app grows. Then deep links arrive, login gates appear, tabs need their own back stacks, and a string of imperative pushes turns into a maze that nobody can reason about. That is the point where most Flutter developers switch to go_router, the declarative routing package maintained by the Flutter team.
This guide covers go_router from setup to production patterns: defining routes, passing parameters, nesting navigation inside tabs, guarding routes with redirects, handling unknown URLs, and wiring up deep links. Every code sample is complete enough to paste into a real project.
Why declarative routing wins
The classic Navigator API is imperative: you tell Flutter what to do, one push at a time. The route stack is hidden state, and rebuilding it from a URL is painful. Deep links, web URLs, and state restoration all need the stack to be derivable from data, not from a history of calls.
go_router flips the model. You declare a route table up front, and the current URL determines the screen stack. Navigation becomes a state change: call context.go('/products/42')\ and the router builds the right screens. The back button, deep links, and browser URLs all fall out of the same mechanism, which is why go_router is the recommended approach for apps that need deep linking or web support.
Setup
Add the package to pubspec.yaml\:
\yaml
dependencies:
flutter:
sdk: flutter
go_router: ^14.0.0
\\
Then create a GoRouter\ instance and hand it to MaterialApp.router\. Note that you do not use the regular MaterialApp\ constructor with home\ anymore — the router owns the initial screen:
\`dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final GoRouter _router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const HomeScreen();
},
),
GoRoute(
path: '/settings',
builder: (BuildContext context, GoRouterState state) {
return const SettingsScreen();
},
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
title: 'GoRouter Demo',
);
}
}
`\
Run flutter pub get\ after adding the dependency. If you keep a home\ argument alongside routerConfig\, the app will not compile, so remove it when you migrate.
Routes, parameters, and query strings
A route path can contain named segments, written with a colon. The values arrive in state.pathParameters\. Query strings arrive separately in state.uri.queryParameters\, which is handy for optional filters that should not be part of the route identity:
\dart
GoRoute(
path: '/products/:id',
builder: (BuildContext context, GoRouterState state) {
final String id = state.pathParameters['id']!;
final String? sort = state.uri.queryParameters['sort'];
return ProductScreen(id: id, sort: sort);
},
),
\\
Navigate with the same URL shape you declared:
\`dart
// Replaces the whole stack: use for top-level destinations.
context.go('/products/42?sort=price');
// Pushes on top of the current stack: use for detail screens.
context.push('/products/42');
`\
The difference between go\ and push\ is the single most misunderstood part of go_router, and it causes most early bugs. Use go\ when the user moves to a new section of the app, and push\ when they drill into a detail they expect to come back from. If the back button behaves strangely, check whether you used go\ where you meant push\ first.
For data that does not belong in a URL — a full object, for example — pass extra\:
\dart
context.push('/checkout', extra: cart);
\\
Read it back from state.extra\ and cast it, but keep a fallback for the case where the app is restored from a deep link and extra\ is null. Anything the screen needs to survive a cold start should live in the URL, not in extra\.
Nested navigation with tabs
Real apps have tabs, and each tab needs its own navigation stack so that switching tabs does not lose the user's place. go_router handles this with StatefulShellRoute.indexedStack\, which keeps a separate navigator per branch:
\dart
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) {
return const HomeScreen();
},
),
],
),
StatefulShellBranch(
routes: <RouteBase>[
GoRoute(
path: '/search',
builder: (BuildContext context, GoRouterState state) {
return const SearchScreen();
},
),
],
),
],
),
\\
The shell widget renders the active branch and a bottom navigation bar that calls navigationShell.goBranch(index)\ on tap. Detail routes can be nested under a branch by adding a routes\ list to its GoRoute\, so a product detail opened from the home tab stays inside the home tab's stack. This is the pattern to reach for before inventing a custom tab navigator.
Guarding routes with redirects
Authentication is the classic redirect case. The redirect\ callback runs on every navigation and returns either a new location or null to allow the navigation:
\`dart
final GoRouter _router = GoRouter(
redirect: (BuildContext context, GoRouterState state) {
final bool loggedIn = authState.isLoggedIn;
final bool loggingIn = state.matchedLocation == '/login';
if (!loggedIn) {
return '/login';
}
if (loggingIn) {
return '/';
}
return null;
},
refreshListenable: authState,
routes: [
GoRoute(
path: '/login',
builder: (BuildContext context, GoRouterState state) {
return const LoginScreen();
},
),
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const HomeScreen();
},
),
],
);
`\
Two details matter here. First, pass a refreshListenable\ — typically a ChangeNotifier\ wrapping your auth state — so the router re-evaluates redirects when login state changes instead of only on navigation. Without it, a successful login will not move the user off the login screen. Second, always exclude the login route itself from the redirect, or you will create an infinite redirect loop that shows a blank screen.
The same pattern covers onboarding, subscription checks, and feature flags: any gate that depends on app state belongs in redirect\, not scattered across button handlers.
Handling unknown routes
When a user opens a URL your app does not know, go_router shows a blank screen unless you tell it otherwise. Set errorBuilder\ on the router to render a proper not-found page, and consider logging the unknown location so you can spot broken links:
\dart
final GoRouter _router = GoRouter(
errorBuilder: (BuildContext context, GoRouterState state) {
return NotFoundScreen(location: state.uri.toString());
},
routes: <RouteBase>[/* ... */],
);
\\
On the web, the error page is also what users see when they mistype a URL, so make it genuinely useful: a short message, a link home, and search if your app has it.
Deep links on Android and iOS
Because go_router is URL-based, deep link support is mostly platform configuration. On Android, add an intent filter with your scheme and host to AndroidManifest.xml\ and verify the equivalent app link for HTTPS URLs. On iOS, add the URL scheme or associated domain to the Xcode project. Then test with a real link, not just an in-app navigation call: run adb shell am start -a android.intent.action.VIEW -d "https://example.com/products/42"\ for Android, or open the link from Notes on iOS.
Two mistakes show up again and again. First, developers test deep links only while the app is already running, and miss the cold-start case where the router must build the full stack from the URL. Second, they put data the detail screen needs only in extra\, which is empty on a cold start from a link. Keep identifiers in the path and fetch the rest from your repository layer.
Common mistakes checklist
| Mistake | Symptom | Fix |
|---|---|---|
Using go\ for detail screens |
Back button exits the section | Use push\ for drill-in navigation |
No refreshListenable\ on auth redirect |
Login does not navigate away | Pass your auth ChangeNotifier\
|
| Redirecting the login page itself | Blank screen or loop | Exclude /login\ from the redirect |
Data only in extra\
|
Empty screen on cold-start deep link | Put IDs in the path, fetch the rest |
No errorBuilder\
|
Blank screen on unknown URLs | Add a not-found screen |
Mixing home\ with routerConfig\
|
Compile error | Remove home\ when migrating |
When not to use go_router
go_router is not mandatory. A two-screen app with no deep links, no tabs, and no web target is simpler with the classic Navigator, and adding a routing package there buys nothing. The switch pays off once you need deep links, web URLs, guarded routes, or per-tab stacks — which, in practice, is most apps that reach production.
If you are structuring a larger app around go_router, pair it with a clean separation between routes and business logic: routes should build screens, and screens should get their data from repositories, as described in Flutter Clean Architecture: A Pragmatic Folder Structure and Flutter Repository Pattern: Keep API Calls Out of Widgets. For state that drives redirects, the patterns in Flutter BLoC Pattern Tutorial: Zero to Production work well with a refreshListenable\.
Wrapping up
Start with a flat route table, add path parameters for detail screens, reach for StatefulShellRoute\ when tabs arrive, and guard everything with a single redirect\ driven by a refreshListenable\. Test deep links cold, keep identifiers in URLs, and add an error page before launch. That covers the navigation needs of the vast majority of Flutter apps, and it keeps routing as data your whole team can read at a glance instead of a history of pushes only the runtime understands.
Get in touch and let's talk about your project.
Originally published at uzairxdev.online
Top comments (0)