go_router became the default Flutter navigation solution around 2023 and by 2026 it is the only router most production apps use. The current version is 14.x, which pairs with go_router_builder to generate typed route classes from annotated Dart. Typed routes eliminate an entire category of runtime bugs. This guide covers the full 2026 setup including shell routes for tabs, auth guards via redirect, deep link integration, and the anti-patterns that still trip people up.
Short version: annotate your routes with @TypedGoRoute, letgo_router_builder generate a .g.dart file with navigation methods, and replace every context.go('/some/string') with a typed method call likeconst OfferRoute(id: 123).go(context). Navigation becomes refactor-safe, parameters become compile-time checked, and your IDE auto-completes every route.
Why go_router won in 2026
The Navigator 1.0 API (Navigator.push) is fine for toy apps but does not scale to deep links, bottom nav, or browser URL sync. Navigator 2.0 (RouterDelegate, RouteInformationParser) is powerful but has an incredibly steep learning curve. go_router sits in the middle: a declarative route table, path-based URLs that sync with the browser on web, shell routes for persistent UI, and typed routes when you want them.
Why teams switched:
- URL-based routing works everywhere including web and deep links.
- Shell routes solve bottom-nav and sidebar layouts without manual state management.
- Redirect handles auth guards with a single function.
- Official maintenance by the Flutter team, not a community project.
- Typed routes via go_router_builder give you compile-time safety.
From strings to types: go_router_builder setup
Without typed routes, navigation looks like this:
context.go('/offer/$id?source=email');
// Runtime error if you typo '/offer/' or forget the param.With go_router_builder:
OfferRoute(id: id, source: 'email').go(context);
// Compile error if you typo. IDE autocomplete. Refactor-safe rename.Setup takes three packages:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
go_router: ^14.0.0
dev_dependencies:
go_router_builder: ^3.0.0
build_runner: ^2.5.0Declare routes as annotated classes:
// lib/routes/app_routes.dart
part 'app_routes.g.dart';
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData with _$HomeRoute {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}
@TypedGoRoute<OfferRoute>(path: '/offer/:id')
class OfferRoute extends GoRouteData with _$OfferRoute {
const OfferRoute({required this.id, this.source});
final int id;
final String? source;
@override
Widget build(BuildContext context, GoRouterState state) =>
OfferScreen(id: id, source: source);
}Run dart run build_runner build --delete-conflicting-outputs and the generator produces the .g.dart file with navigation helpers, parameter parsing, and URL template matching.
Shell routes: tabs, bottom nav, and persistent UI
A ShellRoute wraps a set of child routes inside a persistent widget. Perfect for bottom navigation where the bottom bar stays visible across tab switches. In 2026 theStatefulShellRoute.indexedStack variant is the standard because it preserves the navigation state of each tab independently.
@TypedStatefulShellRoute<HomeShellRoute>(
branches: [
TypedStatefulShellBranch<FeedBranch>(routes: [TypedGoRoute<FeedRoute>(path: '/feed')]),
TypedStatefulShellBranch<ExploreBranch>(routes: [TypedGoRoute<ExploreRoute>(path: '/explore')]),
TypedStatefulShellBranch<ProfileBranch>(routes: [TypedGoRoute<ProfileRoute>(path: '/profile')]),
],
)
class HomeShellRoute extends StatefulShellRouteData {
const HomeShellRoute();
@override
Widget builder(BuildContext context, GoRouterState state, StatefulNavigationShell shell) {
return HomeScaffold(shell: shell);
}
}
class HomeScaffold extends StatelessWidget {
const HomeScaffold({required this.shell});
final StatefulNavigationShell shell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: shell,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: (i) => shell.goBranch(i, initialLocation: i == shell.currentIndex),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Feed'),
NavigationDestination(icon: Icon(Icons.search), label: 'Explore'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}Two rules about shell routes:
- Keep nesting to at most two levels (shell then leaf). Deeper nesting gets confusing fast.
- Use
indexedStackwhen tab state must be preserved. Use regularStatefulShellRoutefor ephemeral tabs.
Auth guards with redirect
The redirect callback on GoRouter is the single function that decides where the user can go. Call it with the current location and return a redirect target, or null to allow.
GoRouter createRouter(AuthBloc auth) => GoRouter(
refreshListenable: GoRouterRefreshStream(auth.stream),
initialLocation: '/',
routes: $appRoutes, // generated
redirect: (context, state) {
final isAuthed = auth.state is Authenticated;
final goingToLogin = state.matchedLocation == '/login';
final publicPaths = {'/login', '/signup', '/forgot-password'};
final isPublic = publicPaths.contains(state.matchedLocation);
if (!isAuthed && !isPublic) return '/login?returnTo=' + Uri.encodeComponent(state.uri.toString());
if (isAuthed && goingToLogin) return '/';
return null;
},
);refreshListenable re-runs the redirect whenever auth state changes. Without it, a user who logs in stays on the login screen.
The returnTo pattern is important for deep links. When an unauthenticated user taps a link to /paywall, you redirect to /login?returnTo=/paywall. After login, the login screen reads returnTo from the query and navigates to the original destination. This is the cleanest way to make deep links survive auth flows.
Deep links into typed routes
Once your routes are typed, deep link handling becomes a single stream that you pipe into go_router. Combine with the app_links package for Universal Links and App Links.
import 'package:app_links/app_links.dart';
final _router = createRouter(authBloc);
final _appLinks = AppLinks();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final initial = await _appLinks.getInitialLink();
if (initial != null) _router.go(initial.path + (initial.query.isNotEmpty ? '?' + initial.query : ''));
_appLinks.uriLinkStream.listen((uri) => _router.go(uri.toString()));
runApp(MaterialApp.router(routerConfig: _router));
}The typed route classes include static methods for parsing URIs, so you can validate deep link parameters at the boundary. Invalid params redirect to a safe fallback.
Parameter parsing and type safety
go_router_builder handles three kinds of parameters:
| Type | How to declare | URL example |
|---|---|---|
| Path param (required) | final int id; with path: '/offer/:id' | /offer/123 |
| Query param (optional) | final String? source; | /offer/123?source=email |
| Extra object (complex data) | final User user; with factory override | Passed in memory, not URL |
Path params cannot be null. Query params can be null. Extra objects let you pass complex Dart objects (an already-loaded User) without serializing through the URL, useful for internal navigation but not for deep links.
Testing navigation
Three patterns for testing go_router navigation:
- Unit test the redirect function. It is pure logic. Construct a
GoRouterStatefake and assert the redirect return value. - Widget test typed route generation. Tap a button that navigates, pump, and find the destination widget. Fast.
- Integration test deep links. Drive the real router with
router.go('/offer/123')and assert the correct screen appears. Covers the parse path without needing a real device link.
testWidgets('offer route renders with typed params', (tester) async {
final router = createRouter(MockAuthBloc(state: Authenticated()));
router.go('/offer/42?source=test');
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
expect(find.byType(OfferScreen), findsOneWidget);
expect(find.text('Offer 42'), findsOneWidget);
});Anti-patterns to avoid
- Do not use
Navigator.pushand go_router in the same app. Pick one. Mixing them breaks the URL sync and the redirect logic. - Do not put business logic in route builders. Route builders should return a widget. Load data in the widget via a BLoC or Riverpod provider.
- Do not use string-based navigation once typed routes are generated.Everyone on the team should use the typed classes.
- Do not nest shell routes deeper than 2. The navigation tree becomes impossible to reason about.
- Do not store navigation state in your BLoCs. go_router is the source of truth for "where the user is." BLoCs store feature state.
- Do not forget to regenerate. If you add a route and don't run
build_runner, the.g.dartfile is stale. Set up a pre-commit hook or CI check.
Common 2026 patterns worth stealing
Three patterns I now copy into every new Flutter project:
- Single
createRouterfactory that takes all dependencies (auth bloc, feature flags, analytics) as parameters. Easy to test with mocks. - Route extension for analytics. Attach a
NavigatorObserverthat logs every route change to PostHog. One line of setup, full navigation funnel data. - Redirect debugging mode. Wrap the redirect function in a dev-mode logger that prints every decision. Saves hours on "why does the app keep bouncing to login."
Migrating from auto_route or Navigator 2.0
Two migration paths commonly come up.
- From auto_route: concepts map closely. Replace
@RoutePagewith@TypedGoRoute. ReplaceAutoRouter.of(context)withcontext.go. Shell routes becomeStatefulShellRoute. Allow one week per feature module. - From Navigator 2.0: your custom
RouterDelegatebecomes obsolete. Walk through everypopRouteandsetNewRoutePathand map to go_router equivalents. Plan three weeks for a medium codebase.
What The Flutter Kit ships
The Flutter Kit ships with a fully-configured go_router 14 setup: typed routes via go_router_builder, a StatefulShellRoute.indexedStack for the bottom nav, an auth guard via redirect plus refreshListenable, deep link integration with app_links, and an analytics observer that logs every route change. Building a new screen is three steps: create the widget, add a@TypedGoRoute class, regenerate.
$69 one-time, unlimited commercial projects. See every integration on the features page or jump to checkout.
Final recommendation
If you are starting a new Flutter app in 2026, go_router with typed routes is the default. If you have an existing app on Navigator 1.0 or auto_route, migrate when you have a week free. The refactor-safety alone pays for the migration time the first time you rename a route.