Flutter Navigation: Scalable Routing with go_router
# Flutter Navigation with go_router
As your Flutter app grows beyond a handful of screens, navigation quickly becomes one of the hardest things to manage. Deep links break, auth guards get scattered across widgets, and your route definitions end up duplicated in ways that are painful to refactor. I have been through this cycle more than once, and go_router is the tool that finally brought sanity to my routing layer.
In this article I will walk you through a complete go_router setup for a medium-sized app, cover migration from the older Navigator APIs, and share the mistakes I have learned to avoid.
Why go_router?
Flutter ships with Navigator 1.0 (imperative push/pop) and Navigator 2.0 (declarative, but verbose). go_router sits on top of Navigator 2.0 and gives you:
Installation
Add go_router to your `pubspec.yaml`:
dependencies:
go_router: ^class="code-number">14.0.class="code-number">0Then run `flutter pub get`.
Basic Route Configuration
Here is the simplest possible setup. A `GoRouter` instance defines your routes and is passed to `MaterialApp.router`:
import class="code-string">'package:go_router/go_router.dart';
final GoRouter router = GoRouter(
initialLocation: class="code-string">'/',
routes: [
GoRoute(
path: class="code-string">'/',
name: class="code-string">'home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: class="code-string">'/settings',
name: class="code-string">'settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: class="code-string">'/profile/:userId',
name: class="code-string">'profile',
builder: (context, state) {
final userId = state.pathParameters[class="code-string">'userId']!;
return ProfileScreen(userId: userId);
},
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
);
}
}Navigation is straightforward:
class=class="code-string">"code-comment">// By path
context.go(class="code-string">'/profile/class="code-number">42');
class=class="code-string">"code-comment">// By name (my preferred approach)
context.goNamed(class="code-string">'profile', pathParameters: {class="code-string">'userId': class="code-string">'class="code-number">42'});
class=class="code-string">"code-comment">// Push onto the stack instead of replacing
context.push(class="code-string">'/settings');Full Setup for a Medium-Sized App
In my apps, I structure routes by feature. Each feature module exports its own list of `RouteBase` objects, and the top-level router simply composes them. This keeps files small and makes it obvious who owns which route.
class=class="code-string">"code-comment">// lib/routing/app_router.dart
import class="code-string">'package:go_router/go_router.dart';
import class="code-string">'package:flutter_riverpod/flutter_riverpod.dart';
import class="code-string">'route_names.dart';
import class="code-string">'auth_routes.dart';
import class="code-string">'home_routes.dart';
import class="code-string">'profile_routes.dart';
import class="code-string">'admin_routes.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
return GoRouter(
initialLocation: class="code-string">'/',
debugLogDiagnostics: true,
refreshListenable: authState,
redirect: (context, state) {
final isLoggedIn = authState.isAuthenticated;
final isOnLoginPage = state.matchedLocation == class="code-string">'/login';
final isOnOnboarding = state.matchedLocation == class="code-string">'/onboarding';
class=class="code-string">"code-comment">// Not logged in — force to login (unless already there)
if (!isLoggedIn && !isOnLoginPage) return class="code-string">'/login';
class=class="code-string">"code-comment">// Logged in but hitting login page — redirect to home
if (isLoggedIn && isOnLoginPage) return class="code-string">'/';
class=class="code-string">"code-comment">// Logged in but onboarding not complete
if (isLoggedIn && !authState.onboardingComplete && !isOnOnboarding) {
return class="code-string">'/onboarding';
}
return null; class=class="code-string">"code-comment">// no redirect
},
routes: [
...authRoutes,
ShellRoute(
builder: (context, state, child) {
return AppShell(child: child);
},
routes: [
...homeRoutes,
...profileRoutes,
...adminRoutes,
],
),
],
errorBuilder: (context, state) => NotFoundScreen(
error: state.error,
),
);
});Route Name Constants
Hardcoding path strings everywhere is the fastest way to introduce typos. I keep all route names in one file:
class=class="code-string">"code-comment">// lib/routing/route_names.dart
abstract class RouteNames {
static const home = class="code-string">'home';
static const login = class="code-string">'login';
static const onboarding = class="code-string">'onboarding';
static const profile = class="code-string">'profile';
static const profileEdit = class="code-string">'profile-edit';
static const settings = class="code-string">'settings';
static const adminDashboard = class="code-string">'admin-dashboard';
static const adminUsers = class="code-string">'admin-users';
static const articleDetail = class="code-string">'article-detail';
}Feature-Based Route Files
class=class="code-string">"code-comment">// lib/routing/home_routes.dart
import class="code-string">'package:go_router/go_router.dart';
import class="code-string">'route_names.dart';
final homeRoutes = <RouteBase>[
GoRoute(
path: class="code-string">'/',
name: RouteNames.home,
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: class="code-string">'article/:articleId',
name: RouteNames.articleDetail,
builder: (context, state) {
final id = state.pathParameters[class="code-string">'articleId']!;
return ArticleDetailScreen(articleId: id);
},
),
],
),
GoRoute(
path: class="code-string">'/settings',
name: RouteNames.settings,
builder: (context, state) => const SettingsScreen(),
),
];Nested Routes and Shell Routes
Shell routes are one of go_router's most powerful features. They let you keep a persistent UI element — like a bottom navigation bar — while swapping only the content area.
ShellRoute(
builder: (context, state, child) {
return ScaffoldWithBottomNav(child: child);
},
routes: [
GoRoute(
path: class="code-string">'/feed',
name: class="code-string">'feed',
builder: (context, state) => const FeedScreen(),
),
GoRoute(
path: class="code-string">'/search',
name: class="code-string">'search',
builder: (context, state) => const SearchScreen(),
),
GoRoute(
path: class="code-string">'/profile',
name: class="code-string">'profile',
builder: (context, state) => const ProfileScreen(),
),
],
)The `ScaffoldWithBottomNav` widget receives the `child` and places it inside its body. The bottom nav bar stays mounted, preserving its state, while only the content transitions.
StatefulShellRoute for Independent Navigation Stacks
If each tab needs its own back stack (think Instagram-style navigation), use `StatefulShellRoute`:
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return MainShell(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: class="code-string">'/feed',
builder: (context, state) => const FeedScreen(),
routes: [
GoRoute(
path: class="code-string">'detail/:postId',
builder: (context, state) => PostDetailScreen(
postId: state.pathParameters[class="code-string">'postId']!,
),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: class="code-string">'/profile',
builder: (context, state) => const ProfileScreen(),
),
],
),
],
)Each branch preserves its own navigation stack. When a user switches tabs and comes back, the previous position is retained.
Redirect Guards
Redirects are the cleanest way to handle auth and permission checks. In my experience, keeping all redirect logic in one top-level function is far better than scattering guards across individual routes.
redirect: (context, state) {
final auth = ref.read(authProvider);
final location = state.matchedLocation;
class=class="code-string">"code-comment">// Public routes that never redirect
const publicRoutes = [class="code-string">'/login', class="code-string">'/register', class="code-string">'/forgot-password'];
if (publicRoutes.contains(location)) {
return auth.isLoggedIn ? class="code-string">'/' : null;
}
class=class="code-string">"code-comment">// Everything else requires auth
if (!auth.isLoggedIn) {
return class="code-string">'/login?redirect=${Uri.encodeComponent(location)}';
}
class=class="code-string">"code-comment">// Admin routes require admin role
if (location.startsWith(class="code-string">'/admin') && !auth.isAdmin) {
return class="code-string">'/';
}
return null;
},Notice the `redirect` query parameter. After login, you can read it and send the user back where they originally wanted to go:
GoRoute(
path: class="code-string">'/login',
builder: (context, state) {
final redirectTo = state.uri.queryParameters[class="code-string">'redirect'] ?? class="code-string">'/';
return LoginScreen(redirectAfterLogin: redirectTo);
},
),Deep Linking Setup
go_router handles deep links automatically on the routing side, but platform configuration is still required.
Android — App Links
Add an intent filter to `android/app/src/main/AndroidManifest.xml`:
<activity ...>
<intent-filter android:autoVerify=class="code-string">"true">
<action android:name=class="code-string">"android.intent.action.VIEW" />
<category android:name=class="code-string">"android.intent.category.DEFAULT" />
<category android:name=class="code-string">"android.intent.category.BROWSABLE" />
<data
android:scheme=class="code-string">"https"
android:host=class="code-string">"yourapp.com"
android:pathPrefix=class="code-string">"/article" />
</intent-filter>
</activity>Host a `.well-known/assetlinks.json` file on your domain to verify ownership.
iOS — Universal Links
Add an Associated Domains entitlement in Xcode:
applinks:yourapp.comHost a `.well-known/apple-app-site-association` file:
{
class="code-string">"applinks": {
class="code-string">"apps": [],
class="code-string">"details": [
{
class="code-string">"appID": class="code-string">"TEAMID.com.yourcompany.yourapp",
class="code-string">"paths": [class="code-string">"/article/*", class="code-string">"/profile/*"]
}
]
}
}Testing Deep Links Locally
class=class="code-string">"code-comment">// Android
adb shell am start -a android.intent.action.VIEW \
-d class="code-string">"https:class="code-commentclass="code-string">">//yourapp.com/article/class="code-number">42" com.yourcompany.yourapp
class=class="code-string">"code-comment">// iOS
xcrun simctl openurl booted class="code-string">"https:class="code-commentclass="code-string">">//yourapp.com/article/class="code-number">42"go_router will parse the incoming URL and match it against your defined routes. If `/article/:articleId` exists in your route tree, the user lands directly on `ArticleDetailScreen(articleId: '42')`.
Migration from Navigator 1.0 / 2.0
From Navigator 1.0 (Imperative)
If your codebase uses `Navigator.push`, `Navigator.pushNamed`, and `Navigator.pop`, migration is straightforward:
| Navigator 1.0 | go_router |
|---|---|
| `Navigator.pushNamed(context, '/profile')` | `context.go('/profile')` or `context.push('/profile')` |
| `Navigator.pop(context)` | `context.pop()` |
| `Navigator.pushReplacementNamed(context, '/home')` | `context.go('/home')` |
| `Navigator.pushAndRemoveUntil(...)` | `context.go('/home')` (clears stack) |
| `onGenerateRoute` | `GoRouter(routes: [...])` |
Key difference: `context.go()` replaces the entire navigation stack to match the target path. `context.push()` adds on top, similar to the old `Navigator.push`.
From Navigator 2.0 (RouterDelegate + RouteInformationParser)
If you already built a custom Router setup, go_router replaces both your `RouterDelegate` and `RouteInformationParser`. You can delete those classes entirely and move your route matching logic into `GoRouter(routes: [...])`.
Migration Strategy
I recommend migrating feature by feature rather than all at once:
Common Routing Mistakes
1. Using `go()` When You Mean `push()`
`context.go('/detail/42')` replaces the stack. If the user presses back, they will not return to the previous screen — they will go to whatever the parent route is. Use `context.push('/detail/42')` when you want a standard back behavior.
2. Forgetting refreshListenable
If your redirect depends on auth state, you need to tell go_router to re-evaluate redirects when that state changes:
GoRouter(
refreshListenable: authStateNotifier, class=class="code-string">"code-comment">// must implement Listenable
redirect: (context, state) { ... },
)Without this, a user who logs out will stay on the protected page until they manually navigate.
3. Duplicating Route Paths
Defining the same path in two different places leads to unpredictable matching. In my route structure, every path is defined exactly once, and I enforce this with a simple test:
test(class="code-string">'no duplicate route paths', () {
final paths = <String>[];
void collectPaths(List<RouteBase> routes, String prefix) {
for (final route in routes) {
if (route is GoRoute) {
final fullPath = class="code-string">'$prefix/${route.path}'.replaceAll(class="code-string">'class=class="code-string">"code-comment">//', class="code-string">'/');
expect(paths.contains(fullPath), isFalse,
reason: class="code-string">'Duplicate path: $fullPath');
paths.add(fullPath);
}
if (route is GoRoute) collectPaths(route.routes, route.path);
if (route is ShellRoute) collectPaths(route.routes, prefix);
}
}
collectPaths(router.configuration.routes, class="code-string">'');
});4. Deep Nesting Without Reason
Every nested route extends the URL path. `/home/feed/post/42/comments/5/reply` is a sign you should flatten your hierarchy. I try to keep nesting to two levels maximum.
5. Not Handling the Error Route
If a user hits a URL that does not match any route, go_router shows a default error page. Always provide your own:
GoRouter(
errorBuilder: (context, state) => const NotFoundScreen(),
)6. Passing Complex Objects via extra
`state.extra` lets you pass any object during navigation, but it breaks deep links (the object is not serializable to a URL). Pass IDs as path or query parameters instead, and fetch the full object on the destination screen.
Testing Your Routes
go_router makes route testing much simpler than raw Navigator 2.0:
test(class="code-string">'redirects unauthenticated user to login', () {
final router = GoRouter(
initialLocation: class="code-string">'/profile',
redirect: (context, state) {
if (!isLoggedIn) return class="code-string">'/login';
return null;
},
routes: [
GoRoute(path: class="code-string">'/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: class="code-string">'/profile', builder: (_, __) => const ProfileScreen()),
],
);
class=class="code-string">"code-comment">// After initialization, the router should have redirected
expect(router.location, class="code-string">'/login');
});Conclusion
go_router has become my default navigation solution for every Flutter project beyond a simple prototype. The combination of declarative routes, centralized redirects, shell routes, and deep link support covers the vast majority of real-world navigation needs. Structure your routes by feature, keep your redirect logic in one place, prefer named routes over raw paths, and you will save yourself hours of debugging.
If you are planning a migration from Navigator 1.0 or 2.0, I can help you design a step-by-step transition plan tailored to your app's architecture.
Related Articles
Clean Architecture in Flutter: Building Scalable Applications
Learn how to apply Clean Architecture in Flutter pragmatically. A practical guide to layers, dependency management, and testable code.
Flutter Form Validation: Usable and Secure Form Flows
Learn practical Flutter form validation patterns for better UX, clear errors, and safer submissions.
Flutter Localization (i18n): Building Multi-Language Apps
Implement scalable localization in Flutter using ARB files, locale management, and translation workflows.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch