Introduction
When you need to navigate between dozens of screens in a Flutter app, hardcoding route strings and managing state manually quickly becomes a maintenance nightmare. Navigation with go_router: practical tips and trade-offs explores a more structured approach using Flutter's declarative routing system, which maps your app's URLs to screens and handles deep links, authentication guards, and nested navigation in a predictable way. By the end of this article, you'll understand how to set up go_router for production apps, recognize when it's the right choice, and avoid the gotchas that trip up most teams on their first serious routing implementation.
Route definitions — Paths map to screens, with parameters
The foundation of go_router is simple: each route is a path that maps to a builder function. Unlike the old named routes system in Flutter, go_router uses a declarative, hierarchical structure that's closer to web routing. You define a GoRouter instance once, usually at app startup, and pass it to your MaterialApp.router.
Here's a basic setup for a CRM app:
import 'package:go_router/go_router.dart';
import 'package:flutter/material.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/leads/:id',
builder: (context, state) => LeadScreen(
id: state.pathParameters['id']!,
),
),
GoRoute(
path: '/leads/:id/edit',
builder: (context, state) => EditLeadScreen(
id: state.pathParameters['id']!,
),
),
],
);
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
title: 'CRM App',
);
}
}Notice the :id syntax — that's a path parameter. When a user navigates to /leads/42, the state.pathParameters map contains {'id': '42'}. You can also use query parameters like /leads?sort=date via state.uri.queryParameters.
A real-world scenario: your sales team opens a link like app://leads/42 from an email campaign. go_router parses that URL, finds the matching route, extracts the id, and builds the correct screen. No manual string parsing, no risk of typos breaking your route logic.
One critical detail: always validate and cast parameters. Path parameters come in as strings, so if you're expecting an integer ID, parse it carefully and handle the error case. Many apps crash in production because they trusted state.pathParameters['id'] without checking it first.
Redirects — Guard routes based on auth state
Authentication and authorization are rarely simple. Your app needs to handle logged-out users, expired tokens, and permission levels. go_router's redirect system lets you intercept navigation and reroute based on app state.
The redirect callback is called before every navigation. Return a new path to redirect, or return null to allow the original path:
import 'package:provider/provider.dart';
final router = GoRouter(
redirect: (context, state) {
final authState = context.read<AuthProvider>();
final isLoggedIn = authState.isLoggedIn;
final isOnLoginPage = state.uri.path == '/login';
// Not logged in, redirect to login
if (!isLoggedIn && !isOnLoginPage) {
return '/login?from=${state.uri.path}';
}
// Already logged in and on login page, go to home
if (isLoggedIn && isOnLoginPage) {
return '/';
}
return null; // Allow navigation
},
routes: [
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
],
);
This redirect runs on every navigation, including deep links. If a user taps a link to /leads/42 but isn't authenticated, they're sent to /login?from=/leads/42 instead. After login, your LoginScreen can check the from query parameter and navigate back to where they came from.
A production tip: keep redirect logic lightweight. If your redirect callback is parsing token expiry, hitting a database, or making network calls, you're slowing down every navigation. Instead, pre-compute auth state in your state management layer (Provider, Riverpod, BLoC) and just read it in the redirect. Remember that redirect runs in a synchronous context — async work belongs in your auth provider's initialization or refresh logic.
Shell routes — Persistent bottom navigation with nested navigators
Most mobile apps have persistent UI like a bottom navigation bar. When you tap a tab, the screen changes but the bar stays. Implementing this cleanly with routing requires nested navigators, and go_router provides ShellRoute for exactly this purpose.
Consider a marketplace app with tabs for home, search, orders, and profile. Each tab has its own navigation stack:
final router = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => ScaffoldWithNavBar(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/search',
builder: (context, state) => const SearchScreen(),
),
GoRoute(
path: '/orders',
builder: (context, state) => const OrdersScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
],
),
],
);
class ScaffoldWithNavBar extends StatefulWidget {
final Widget child;
const ScaffoldWithNavBar({required this.child, Key? key}) : super(key: key);
@override
State<ScaffoldWithNavBar> createState() => _ScaffoldWithNavBarState();
}
class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: widget.child,
bottomNavigationBar: BottomNavigationBar(
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.shopping_bag), label: 'Orders'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
onTap: (index) {
switch (index) {
case 0:
context.go('/');
break;
case 1:
context.go('/search');
break;
case 2:
context.go('/orders');
break;
case 3:
context.go('/profile');
break;
}
},
),
);
}
}The ShellRoute wraps all child routes with a common layout (the scaffold with bottom nav). When you navigate from `/orders` to `/search`, go_router rebuilds the child while keeping the ScaffoldWithNavBar widget in the tree — the bottom nav doesn't flicker or reset.
Each tab can have nested routes too. For instance, tapping a product in the search tab might open `/search/product/123`, then tapping "View reviews" opens `/search/product/123/reviews`. When you tap the Search tab again, you return to where you left off in that tab's stack. This is the behavior users expect from modern apps, and go_router handles it automatically.
Typed routes — Code generation for type-safe navigation
Relying on string paths is error-prone. If you rename a route from `/leads/:id` to `/prospects/:id`, you have to hunt down every place you call context.push('/leads/$leadId') and update it manually. Type-safe routing using code generation eliminates this class of bugs.
The go_router_builder package generates route classes from your route definitions using a simple Dart annotation pattern:
import 'package:go_router/go_router.dart';
part 'router.g.dart';
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
@override
Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}
@TypedGoRoute<LeadRoute>(path: '/leads/:id')
class LeadRoute extends GoRouteData {
final String id;
LeadRoute({required this.id});
@override
Widget build(BuildContext context, GoRouterState state) =>
LeadScreen(id: id);
}
@TypedGoRoute<EditLeadRoute>(path: '/leads/:id/edit')
class EditLeadRoute extends GoRouteData {
final String id;
EditLeadRoute({required this.id});
@override
Widget build(BuildContext context, GoRouterState state) =>
EditLeadScreen(id: id);
}
final router = GoRouter(
routes: $appRoutes,
);
Now you navigate using strongly-typed classes instead of strings:
// Instead of: context.push('/leads/42')
// You write:
context.push(LeadRoute(id: '42').location);
// The IDE autocompletes the parameters and catches errors at compile time
In practice, this catches bugs during code review and in your test suite long before they reach production. Refactoring routes is as simple as renaming the class — the compiler tells you everywhere that needs updating.
Common mistakes
- Blocking the redirect with complex async logic: If your auth provider is still loading when the app starts, the router's redirect might send users to the wrong place. Make sure your auth state is initialized before you build the router or that your app shows a splash screen while auth is being resolved.
- Forgetting to handle null or invalid path parameters: Always validate and parse path parameters, especially IDs. Use try-catch or null coalescing to provide sensible fallbacks.
- Mixing implicit and explicit navigation: Some places use
context.push(), others use named routes, and others push raw strings. This inconsistency leads to maintenance headaches. Pick one style — preferably typed routes — and stick to it across the team. - Overcomplicating the route tree: Not every screen needs its own route. If a screen is only shown in a modal or dialog within another screen, consider managing it with local state rather than as a separate route.
- Ignoring deep link testing: Test that your redirect logic and parameter parsing work correctly when the app is launched from a deep link, not just from in-app navigation. Cold start scenarios often reveal issues that hot navigation hides.
Key takeaways
- go_router replaces the old named routes system with a declarative, hierarchical structure that scales better as your app grows.
- Path parameters like
:idare parsed intostate.pathParameters, but always validate them before use. - The redirect callback is your guard for authentication and authorization; keep it lightweight by reading pre-computed state from your state management layer.
- ShellRoute manages persistent UI like bottom navigation bars and keeps each tab's navigation stack independent.
- Type-safe routing with go_router_builder catches parameter errors at compile time and makes refactoring routes much safer.
- Deep links, redirects, and nested navigation all work together — test them thoroughly on cold app launches, not just during development.






Comments
Loading comments…