flutter
/

BLoC Route Management: Advanced Navigation & Deep Linking

Last Sync: Today

On this page

9
0%
Advanced
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutterAdvanced

BLoC Route Management: Advanced Navigation & Deep Linking

In complex Flutter apps, navigation is more than just pushing screens – it involves route guards, nested navigation, deep linking, and state synchronization. By combining BLoC with a routing solution like go_router, you can build a declarative, testable navigation system that responds to your app’s state. This guide explores advanced route management techniques using BLoC.

Why Combine BLoC with Advanced Routing?

  • Declarative navigation – Routes are defined based on the current state (e.g., show login if not authenticated).
  • Route guards – Protect routes based on authentication or user roles without sprinkling checks everywhere.
  • Deep linking – Handle URLs seamlessly, converting them to the correct app state.
  • Testability – Navigation logic becomes part of the state machine, easy to test in isolation.
  • Scalability – Manage complex nested navigation (tabs, drawers) with clean separation.

  1. Route Guards with go_router and BLoC

go_router is the recommended routing package for Flutter. It supports redirects based on conditions – perfect for route guards. You can integrate a BLoC to check authentication state and redirect accordingly.

DARTRead-only
1
final router = GoRouter(
  initialLocation: '/',
  redirect: (context, state) {
    final authState = context.read<AuthBloc>().state;
    final isLoggedIn = authState is AuthSuccess;
    final isLoginRoute = state.subloc == '/login';

    if (!isLoggedIn && !isLoginRoute) {
      return '/login';
    }
    if (isLoggedIn && isLoginRoute) {
      return '/';
    }
    return null;
  },
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomePage(),
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => LoginPage(),
    ),
    GoRoute(
      path: '/profile',
      builder: (context, state) => ProfilePage(),
    ),
  ],
);

In this example, the redirect function reads the current AuthBloc state and decides where to go. This centralises all route protection logic.

  1. Nested Navigation with BLoC and ShellRoute

For tab‑based navigation (e.g., bottom navigation bar), you can use ShellRoute to define a common shell that stays persistent. The selected tab can be controlled by a BLoC that emits the current tab index.

DARTRead-only
1
class TabState extends Equatable {
  final int index;
  const TabState(this.index);
  @override List<Object?> get props => [index];
}

class TabBloc extends Cubit<TabState> {
  TabBloc() : super(const TabState(0));
  void setTab(int index) => emit(TabState(index));
}
DARTRead-only
1
final router = GoRouter(
  initialLocation: '/',
  routes: [
    ShellRoute(
      builder: (context, state, child) {
        return ScaffoldWithNavBar(
          child: child,
        );
      },
      routes: [
        GoRoute(
          path: '/',
          builder: (context, state) => HomePage(),
        ),
        GoRoute(
          path: '/search',
          builder: (context, state) => SearchPage(),
        ),
        GoRoute(
          path: '/profile',
          builder: (context, state) => ProfilePage(),
        ),
      ],
    ),
  ],
);
DARTRead-only
1
class ScaffoldWithNavBar extends StatelessWidget {
  final Widget child;
  const ScaffoldWithNavBar({required this.child});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<TabBloc, TabState>(
      builder: (context, state) {
        return Scaffold(
          body: child,
          bottomNavigationBar: BottomNavigationBar(
            currentIndex: state.index,
            onTap: (index) {
              context.read<TabBloc>().setTab(index);
              // Update route based on index
              switch (index) {
                case 0: context.go('/'); break;
                case 1: context.go('/search'); break;
                case 2: context.go('/profile'); break;
              }
            },
            items: const [
              BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
              BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
              BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
            ],
          ),
        );
      },
    );
  }
}

  1. Deep Linking with BLoC

Deep links (URLs) can be handled by go_router which parses the URL and builds the corresponding route. You still need to ensure the app is in the correct state (e.g., user logged in) before navigating. The redirect function already handles this. For dynamic parameters, use path parameters.

DARTRead-only
1
GoRoute(
  path: '/product/:id',
  builder: (context, state) {
    final id = state.pathParameters['id']!;
    return ProductPage(id: id);
  },
);

To test deep linking on Android/iOS, you need to configure the app's manifest/Info.plist. With go_router, the deep link URL will be automatically matched and the router will handle it.

  1. Using BLoC with go_router for Authentication Flow

Here’s a complete example of an authentication flow using BLoC and go_router, including a splash screen that checks the auth state.

DARTRead-only
1
// auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc() : super(AuthInitial()) {
    on<AuthCheckRequested>(_onAuthCheckRequested);
    on<AuthLogin>(_onAuthLogin);
    on<AuthLogout>(_onAuthLogout);
  }

  Future<void> _onAuthCheckRequested(AuthCheckRequested event, Emitter<AuthState> emit) async {
    final token = await storage.readToken();
    if (token != null) {
      emit(AuthSuccess());
    } else {
      emit(AuthFailure());
    }
  }

  // ... other handlers
}

// router.dart
final router = GoRouter(
  initialLocation: '/',
  redirect: (context, state) {
    final authState = context.read<AuthBloc>().state;
    final isLoggedIn = authState is AuthSuccess;
    final isLoginRoute = state.subloc == '/login';

    if (authState is AuthInitial) return '/splash';
    if (!isLoggedIn && !isLoginRoute) return '/login';
    if (isLoggedIn && isLoginRoute) return '/';
    return null;
  },
  routes: [
    GoRoute(path: '/splash', builder: (context, state) => SplashPage()),
    GoRoute(path: '/login', builder: (context, state) => LoginPage()),
    GoRoute(path: '/', builder: (context, state) => HomePage()),
    GoRoute(path: '/profile', builder: (context, state) => ProfilePage()),
  ],
);

// splash_page.dart
class SplashPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    context.read<AuthBloc>().add(AuthCheckRequested());
    return const Scaffold(body: Center(child: CircularProgressIndicator()));
  }
}

  1. Testing Route Guards and Navigation

Test your route logic by setting up a test environment with a mock AuthBloc and checking the router’s behavior. You can use go_router’s MaterialApp.router and test navigation using tester.pumpAndSettle and verifying the current route.

DARTRead-only
1
testWidgets('redirects to login when not authenticated', (tester) async {
  final authBloc = MockAuthBloc();
  when(authBloc.state).thenReturn(AuthFailure());

  await tester.pumpWidget(
    BlocProvider.value(
      value: authBloc,
      child: MaterialApp.router(
        routerConfig: router,
      ),
    ),
  );

  await tester.pumpAndSettle();
  expect(find.text('Login'), findsOneWidget);
});

Best Practices

  • Use go_router with BLoC – It’s the most robust solution for complex navigation, supporting redirects, nested routes, and deep linking.
  • Centralise route guards – Put all protection logic in the redirect function, not scattered across screens.
  • Keep navigation state in BLoC – For tab selection, drawer state, etc., use a BLoC to manage them.
  • Test navigation separately – Use widget tests to verify that the correct screens appear given the BLoC state.
  • Handle deep links gracefully – Ensure your app can navigate to the correct content even if the user isn’t logged in (by queuing the intended route after login).
  • Use path parameters for IDs – Makes deep linking easier and URLs cleaner.

Common Mistakes

  • ❌ Using Navigator.push inside BLoC – Bypasses the routing system and makes guards ineffective. ✅ Let the router handle navigation; use context.go() or router.push().
  • ❌ Not handling AuthInitial – The app may show a blank screen or incorrect route while checking auth. ✅ Redirect to a splash screen or show a loader.
  • ❌ Hardcoding routes in multiple places – Leads to maintenance issues. ✅ Use route constants and a single source of truth.
  • ❌ Forgetting to provide BLoC to the router – The router’s redirect can’t access the bloc. ✅ Wrap the app with BlocProvider or use Provider above the router.
  • ❌ Mixing navigation with business logic – Navigation decisions should be based on state, not internal logic of blocs. ✅ Let the router decide based on the current state.

Conclusion

Combining BLoC with a declarative routing solution like go_router unlocks powerful, maintainable navigation for Flutter apps. Route guards become a simple redirect function, nested navigation is easily handled with ShellRoute, and deep linking works out of the box. By keeping navigation logic separate from business logic and relying on state, you build apps that are both robust and testable.

Test Your Knowledge

Q1
of 3

What is the main advantage of using go_router with BLoC for route guards?

A
It allows calling Navigator directly from the bloc
B
It centralises route protection in a single redirect function
C
It removes the need for BLoC
D
It automatically handles all state management
Q2
of 3

Which widget would you use to create a persistent shell with tabs in go_router?

A
GoRoute
B
ShellRoute
C
StatefulShellRoute
D
NestedRoute
Q3
of 3

Why should navigation decisions not be made inside a bloc?

A
Because it's not possible
B
It couples the bloc to the UI framework and makes testing harder
C
It causes runtime errors
D
It increases code size

Frequently Asked Questions

Should I use go_router or the default Navigator with BLoC?

For complex apps with route guards, nested navigation, and deep linking, go_router is highly recommended. It integrates seamlessly with BLoC via the redirect function and reduces boilerplate. For very simple apps, the default Navigator with BlocListener may suffice.

How do I handle deep links that require authentication?

In the redirect function, you can store the intended route (e.g., from the deep link) in a variable and after successful login, navigate to that route. Alternatively, you can use the go_router onRedirect to handle it.

Can I use BLoC with other routing packages like auto_route?

Yes, auto_route also supports guards and can be integrated with BLoC. The principles are similar: use a guard that reads the BLoC state and decides whether to allow navigation.

How do I test the router with BLoC?

Use widget tests: set up a test widget tree with BlocProvider providing a mocked bloc, and then use tester.pumpWidget and tester.expect to verify that the correct screen is shown after navigation or redirect.

What about handling Web URL routes?

go_router works seamlessly on the web. The redirect function is also executed for initial URL loading, so you can protect routes and handle deep links uniformly across platforms.

Previous

bloc navigation

Next

bloc navigation events

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.