Intermediate · Lesson 7 · 13 min read
Declarative Routing with go_router
Move past Navigator.push: typed routes, deep links, auth redirects and a bottom navigation bar where every tab keeps its own history.
Updated August 1, 2026
What you will learn
- Define a route table with path and query parameters
- Guard routes with redirects based on auth state
- Build nested navigation with a persistent bottom bar
- Handle deep links and web URLs correctly
Navigator.push is imperative: you say go here now. Declarative routing inverts it — you describe every route the app has, and the router decides what to display for a given URL. That inversion is what makes deep links, browser back buttons and auth guards work properly.
A basic route table
import 'package:go_router/go_router.dart';
final router = GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
routes: [
// Nested: resolves to /lesson/:id
GoRoute(
path: 'lesson/:id',
name: 'lesson',
builder: (context, state) {
final id = state.pathParameters['id']!;
final level = state.uri.queryParameters['level'];
return LessonScreen(id: id, level: level);
},
),
],
),
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginScreen(),
),
],
errorBuilder: (context, state) => NotFoundScreen(uri: state.uri),
);
// Wire it into the app
MaterialApp.router(
routerConfig: router,
theme: buildTheme(Brightness.light),
);// Navigate by path
context.go('/settings'); // replace the stack
context.push('/lesson/widgets-101'); // push onto the stack
// Navigate by name — refactor-safe, no string paths in your widgets
context.goNamed('lesson', pathParameters: {'id': 'widgets-101'});
context.pushNamed(
'lesson',
pathParameters: {'id': 'widgets-101'},
queryParameters: {'level': 'beginner'},
);
context.pop();Redirects: the auth guard
final router = GoRouter(
// Rebuilds routing whenever auth state changes
refreshListenable: authNotifier,
redirect: (context, state) {
final loggedIn = authNotifier.isLoggedIn;
final goingToLogin = state.matchedLocation == '/login';
// Not signed in and heading somewhere protected → login,
// remembering where they wanted to go
if (!loggedIn && !goingToLogin) {
return '/login?from=${Uri.encodeComponent(state.matchedLocation)}';
}
// Already signed in but sitting on the login screen → home
if (loggedIn && goingToLogin) {
return state.uri.queryParameters['from'] ?? '/';
}
// null means "no redirect, proceed"
return null;
},
routes: [/* ... */],
);A single top-level redirect handles the entire app. There is no need to check auth in each screen's initState, and no window where a protected screen flashes before the check runs.
Nested navigation with a bottom bar
StatefulShellRoute gives each tab its own navigation stack that survives switching tabs — the behaviour users expect from native apps.
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return ScaffoldWithNavBar(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/feed',
builder: (context, state) => const FeedScreen(),
routes: [
GoRoute(
path: 'article/:id',
builder: (context, state) =>
ArticleScreen(id: state.pathParameters['id']!),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: '/search', builder: (context, state) => const SearchScreen()),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: '/profile', builder: (context, state) => const ProfileScreen()),
],
),
],
)class ScaffoldWithNavBar extends StatelessWidget {
const ScaffoldWithNavBar({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) => navigationShell.goBranch(
index,
// Tapping the current tab again pops it back to its root
initialLocation: index == navigationShell.currentIndex,
),
destinations: const [
NavigationDestination(icon: Icon(Icons.feed_outlined), label: 'Feed'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Profile'),
],
),
);
}
}Deep links
Because routes are described by URL, a deep link is just a location the router already knows. You only need to tell the platform to hand those URLs to your app.
<activity ...>
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="flutterlearn.dev" />
</intent-filter>
</activity># Test a deep link without publishing anything
adb shell am start -a android.intent.action.VIEW \
-d "https://flutterlearn.dev/lesson/widgets-101" dev.flutterlearn.app
# iOS simulator
xcrun simctl openurl booted "https://flutterlearn.dev/lesson/widgets-101"Type-safe routes
// With go_router_builder, routes are generated from annotated classes
@TypedGoRoute<LessonRoute>(path: '/lesson/:id')
class LessonRoute extends GoRouteData {
const LessonRoute({required this.id, this.level});
final String id;
final String? level;
@override
Widget build(BuildContext context, GoRouterState state) =>
LessonScreen(id: id, level: level);
}
// Navigation becomes a compile-checked constructor call
const LessonRoute(id: 'widgets-101', level: 'beginner').push(context);The generated approach costs a build_runner step but removes every stringly-typed path from your widgets. On a codebase with thirty routes and several developers, that trade is usually worth making.
Key takeaways
- Declarative routing describes every route once; the router resolves what to show.
goreplaces the stack,pushextends it — mixing them causes odd back behaviour.- One top-level
redirectplusrefreshListenablehandles auth for the whole app. StatefulShellRoutegives each bottom-bar tab its own persistent stack.
Practice
Convert an app to go_router
Take an app using Navigator and named routes and move it to go_router. Add a login redirect that preserves the intended destination, a three-tab shell where each tab keeps its own history, and a deep link to a detail screen. Verify the link with adb or simctl.
Show hints
- Start with the route table alone, then add the redirect, then the shell — one at a time.
- Set
debugLogDiagnostics: trueto see exactly which route matched and why.