Skip to content
FlutterLearn

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

lib/router.dart
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),
);
Dart
// 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

Dart
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.

Dart
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()),
      ],
    ),
  ],
)
Dart
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'),
        ],
      ),
    );
  }
}

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.

android/app/src/main/AndroidManifest.xml
<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>
Terminal
# 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

Dart
// 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.
  • go replaces the stack, push extends it — mixing them causes odd back behaviour.
  • One top-level redirect plus refreshListenable handles auth for the whole app.
  • StatefulShellRoute gives 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: true to see exactly which route matched and why.