Flutter go_router Navigation Guide: From Basics to Advanced Patterns
A practical guide to Flutter’s go_router: setup, parameters, guards, nested tabs, deep links, transitions, and testing with concise, production-ready code.
Image used for representation purposes only.
Overview
Flutter’s go_router brings a declarative, URL-first approach to navigation that works consistently across mobile, web, and desktop. It simplifies deep links, nested navigation, guards/redirects, and testing—without fighting the Navigator API directly. This guide walks through practical patterns you can drop into production.
Quick start
- Add the package:
flutter pub add go_router
- Create a router and wire it into MaterialApp.router:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
title: 'go_router demo',
theme: ThemeData(useMaterial3: true),
);
}
}
final _router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'details/:id',
name: 'details',
builder: (context, state) {
final id = state.pathParameters['id']!;
final tab = state.uri.queryParameters['tab'];
final item = state.extra as Item?; // Optional typed payload
return DetailsScreen(id: id, initialTab: tab, item: item);
},
),
],
),
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const SettingsScreen(),
),
],
errorBuilder: (context, state) => ErrorScreen(error: state.error),
);
Key ideas:
- Use name for routes to avoid hard-coded string paths in your app code.
- Nested routes let you define children relative to their parent (e.g.,
details/:id). GoRouterStateexposespathParameters,uri.queryParameters, andextra.
Navigating: go vs push (and friends)
context.go('/settings')replaces the current stack with a new location. Best for top-level navigation (e.g., switching tabs or visiting a new root).context.push('/details/42')pushes a new page on top of the stack. Best for drill-in flows.- Named navigation avoids manual string interpolation:
// Build the URL from route name + parameters
context.goNamed(
'details',
pathParameters: {'id': '42'},
queryParameters: {'tab': 'info'},
extra: const Item(id: '42', title: 'Example'),
);
// Return to previous page
context.pop();
Tips:
- Prefer named APIs (
goNamed,pushNamed) for maintainability. - Use
extrafor passing non-serializable objects in-app; use query parameters for shareable/deep-linkable state.
Reading parameters and data
class DetailsScreen extends StatelessWidget {
const DetailsScreen({super.key, required this.id, this.initialTab, this.item});
final String id;
final String? initialTab;
final Item? item;
factory DetailsScreen.fromState(GoRouterState state) => DetailsScreen(
id: state.pathParameters['id']!,
initialTab: state.uri.queryParameters['tab'],
item: state.extra as Item?,
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Details $id')),
body: Text('Tab: ${initialTab ?? 'default'} | Item: ${item?.title ?? '-'}'),
);
}
}
- Path parameters are required by definition; check for null only if you have parallel optional routes.
- Query parameters are strings; parse/validate as needed.
- Keep parsing close to the page boundary (e.g., via a factory constructor) to keep widgets lean.
Guards and redirects (auth, onboarding, paywalls)
You can enforce navigation rules globally or per-route.
Global redirect with a refresh signal:
final auth = AuthController();
final router = GoRouter(
refreshListenable: GoRouterRefreshStream(auth.stream),
redirect: (context, state) {
final loggedIn = auth.isLoggedIn;
final loggingIn = state.matchedLocation == '/login';
if (!loggedIn) return loggingIn ? null : '/login';
if (loggingIn) return '/';
return null; // no redirect
},
routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
],
);
Per-route redirect (fine-grained guards):
GoRoute(
path: '/profile',
name: 'profile',
redirect: (context, state) => auth.isLoggedIn ? null : '/login',
builder: (context, state) => const ProfileScreen(),
)
Notes:
- Return a String path to redirect, or
nullto allow. - Use
refreshListenable(e.g.,GoRouterRefreshStream,ChangeNotifier) so guards re-evaluate when auth state changes.
Nested navigation and bottom tabs
Two powerful patterns:
- ShellRoute: Wraps child routes in a common UI (e.g., shared Scaffold). It does not preserve independent stacks per tab.
- StatefulShellRoute.indexedStack: Multiple branches with their own Navigators, preserving state across tab switches.
Bottom navigation with preserved state:
final router = GoRouter(
routes: [
StatefulShellRoute.indexedStack(
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/feed',
name: 'feed',
builder: (context, state) => const FeedScreen(),
routes: [
GoRoute(
path: 'post/:id',
name: 'post',
builder: (context, state) => PostScreen(
id: state.pathParameters['id']!,
),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/search',
name: 'search',
builder: (context, state) => const SearchScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfileScreen(),
),
],
),
],
builder: (context, state, navigationShell) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) => navigationShell.goBranch(index),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Feed'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
},
),
],
);
Tip: Use navigationShell.goBranch(index, initialLocation: true) to reset a branch to its root when re-selecting the current tab.
Layout shells (shared scaffolds, drawers)
Use ShellRoute to provide persistent UI around child pages without separate stacks:
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
routes: [
GoRoute(path: '/inbox', builder: (context, state) => const InboxScreen()),
GoRoute(path: '/sent', builder: (context, state) => const SentScreen()),
],
)
Error pages and unknown routes
- Provide a friendly 404/unknown route with
errorBuilder. - From
GoRouterState, inspectstate.erroror display the attemptedstate.uri.toString().
class ErrorScreen extends StatelessWidget {
const ErrorScreen({super.key, this.error});
final Exception? error;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Page not found')),
body: Center(child: Text(error?.toString() ?? 'The page does not exist.')),
);
}
}
Custom transitions and pages
Swap the default transition per route using pageBuilder and CustomTransitionPage:
GoRoute(
path: '/modal',
name: 'modal',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
barrierColor: Colors.black54,
transitionsBuilder: (context, animation, secondary, child) {
final tween = Tween(begin: const Offset(0, 1), end: Offset.zero)
.chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
child: const ModalScreen(),
),
)
Use NoTransitionPage for instant switches (e.g., between tabs).
Deep links and Flutter web URLs
- Deep linking works out of the box when you use
MaterialApp.routerwithGoRouter. - On Flutter web, remove the hash from URLs:
import 'package:flutter_web_plugins/url_strategy.dart';
void main() {
setUrlStrategy(PathUrlStrategy());
runApp(const MyApp());
}
- For mobile deep links (Android App Links / iOS Universal Links), configure platform-side intents/associations; go_router will match the incoming path to your route table.
State restoration and the back button
- go_router integrates with platform back gestures and browser history.
context.goreplaces history entries;context.pushadds them. Choose deliberately to make the back stack feel natural.- In nested stacks, the current branch pops first; switching branches preserves each branch’s stack when using
StatefulShellRoute.indexedStack.
Testing your routes
Widget test navigation flows without spinning up a full app shell:
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() {
testWidgets('navigates to details screen', (tester) async {
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => Builder(
builder: (context) => TextButton(
onPressed: () => context.push('/details/1'),
child: const Text('Open'),
),
),
),
GoRoute(
path: '/details/:id',
builder: (context, state) => Text('Details ${state.pathParameters['id']}'),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('Details 1'), findsOneWidget);
});
}
Tips:
- Use
router.go('/path')in tests to jump directly to a location and assert the rendered UI. - For auth flows, inject a fake auth controller and trigger stream updates to verify redirects.
Organization tips for production
- Centralize routes in a
router.dart(or feature-local routers for modular apps). - Prefer named routes; co-locate parameter parsing with page constructors.
- Keep redirects side-effect free—only compute and return a path or null.
- For large apps, consider typed routing via code generation (e.g., annotations + a builder) to reduce runtime URL mistakes.
Common pitfalls and fixes
- “Nothing happens on tap”: Ensure you used
MaterialApp.router(routerConfig: router)and not the legacyonGenerateRouteAPIs. - Query parameters missing: Read them from
state.uri.queryParameters, not frompathParameters. - Back button leaves the app: You likely used
gowhere apushfit better. Replace withpushto create a back-stack entry. - Tabs lose scroll position: Use
StatefulShellRoute.indexedStackinstead ofShellRouteto preserve each branch state. - Extra is null on hot restart:
extrais in-memory only. For shareable state, encode it in the URL path/query.
Conclusion
go_router reduces navigation complexity while embracing Flutter’s multi-platform nature. Start with a clean route table, lean on named navigation, add guards where needed, and use StatefulShellRoute.indexedStack for tabbed apps. With deep links, custom transitions, and solid testability, you’ll ship a navigation experience that feels native everywhere.
Related Posts
Flutter BLoC + Clean Architecture: A Practical Guide with Patterns and Code
A practical, end-to-end guide to combining Flutter’s BLoC pattern with Clean Architecture using code, structure, DI, and testing tips.
Flutter GetX Tutorial: State, Routing, and Dependency Injection in One Lightweight Package
Step-by-step Flutter GetX tutorial covering state, routing, DI, bindings, workers, theming, and i18n with practical code snippets.
Flutter Web Development Guide: Build, Optimize, and Ship Fast Web Apps
A practical, end-to-end Flutter web development guide: setup, routing, responsive layouts, performance, SEO, PWAs, testing, and deployment tips.