CodeNewbie Community 🌱

Cover image for How to Implement Type-Safe Navigation with go_router in Flutter
Nandani Sharma
Nandani Sharma

Posted on • Originally published at canopas.com

How to Implement Type-Safe Navigation with go_router in Flutter

Exciting News! Our blog has a new Home! 🚀

Background

With type-safe navigation, your navigation logic becomes consistent and maintainable, significantly simplifying debugging and future code modifications.

This technique is particularly beneficial when building Flutter apps for the web, as it seamlessly manages URLs and ensures smooth navigation experiences.

In this blog, we’ll explore how to implement type-safe navigation in Flutter using the go_router and go_router_builder packages.

By the end, you’ll have a comprehensive understanding of setting up type-safe routes, generating code, and managing navigation in your Flutter applications.

Introduction

Type-safe navigation ensures that your navigation logic is consistent and free from errors.

It eliminates the risk of parsing parameters incorrectly and typos in route names and parameters, making your code more maintainable and easier to debug.

When building Flutter apps that target the web, type-safe navigation helps manage URLs easily.

Let’s Get Started

We’ll break down the whole thing into 5 easy steps so you can understand it better.

Step 1: Add Dependencies
Add dependencies to your pubspec.yaml file.

dependencies:
  # Necessary for utilizing the Router API-based navigation.
  go_router: <Letest Version> 

dev_dependencies:
  # Tool for generating type-safe routes with go_router.
  go_router_builder: <Letest Version>
  # Tool for running code generation for go_router_builder..
  build_runner: <Letest Version>
Enter fullscreen mode Exit fullscreen mode

Step 2: Define Routes

Now create a class for each screen and extend it with GoRouteData and add annotations to top-level routes using @TypedGoRoute().

Ensure all classes are created in a single file for easier code generation.

@TypedGoRoute<HomeRoute>(
  path: '/',
  routes: [
    TypedGoRoute<ItemDetailsRoute>(path: 'items/:id')
  ],
) 
class HomeRoute extends GoRouteData {
  @override
  Widget build(BuildContext context, GoRouterState state) 
    => const HomeScreen();
}

class ItemDetailsRoute extends GoRouteData {
  final String id;
  const ItemDetailsRoute({required this.id});

  @override
  Widget build(BuildContext context, GoRouterState state) =>
      ItemDetailsScreen(id: id);
}

@TypedGoRoute<SignInRoute>(
  path: '/sign-in',
  routes: [
    TypedGoRoute<VerifyOtpRoute>(path: "verify"),
  ], 
)
class SignInRoute extends GoRouteData {
  @override
  Widget build(BuildContext context, GoRouterState state) =>
      const SignInScreen();
}

class VerifyOtpRoute extends GoRouteData {
  final String $extra;

  const VerifyOtpRoute({required this.$extra});

  @override
  Widget build(BuildContext context, GoRouterState state) => 
     VerifyOtpScreen(verificationId: $extra);
}
Enter fullscreen mode Exit fullscreen mode

In this code, we have created a class for each screen by extending GoRouteData and annotating it with TypedGoRoute. We have also passed some data into another screen.

Let’s explore this in more detail.

GoRouteData: GoRouteData is an abstract class that contains methods that can be overridden to return a screen, or page or to redirect the user to another page. You must use at least one of these methods.

class HomeRoute extends GoRouteData {

  // You can define the parentNavigationKey like this. (optional)
  static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;

  @override
  Widget build(BuildContext context, GoRouterState state) {
    // The widget returned here will be displayed when the user navigates to this route path.
    return const HomeScreen();
}

  @override
  Page<void> buildPage(BuildContext context, GoRouterState state) {
    // The Page returned here will be displayed when the user navigates to this route path.
    // Here, you can also set page transitions by returning CustomTransitionPage.
    return const CupertinoPage(child: HomeScreen());
  }

  @override
  String? redirect(BuildContext context, GoRouterState state){
      // Here, you can specify the location or path to which the user should be redirected when navigating to this route.
      return "/login";
      // Return null to prevent redirect.
  }
}
Enter fullscreen mode Exit fullscreen mode

TypedGoRoute: TypedGoRoute annotation is used to define the route tree. We need to annotate every top-level route class with TypedGoRoute to generate the route list.

@TypedGoRoute<TopLevelRoute>(
  path: '/top-level-route-path',
  routes: [
    // you can define sub-routes annotation here like this.
    TypedGoRoute<SubRoute>(
        path: 'sub-route-path'
        routes: []
        name: 'sub route'
    )
  ],
  name: 'top level route' // Optional
)
Enter fullscreen mode Exit fullscreen mode

Note: It is required to add a generic type in the TypedGoRoute annotation, like this:

@TypedGoRoute<MyRouteGeneric>()
Enter fullscreen mode Exit fullscreen mode

Now let’s see how we can use query parameters, path parameters and extra in route.

Path Parameters:

  • Path parameters are defined within the route path using : symbol (e.g., /products/:id).
  • Represent specific parts of the URL structure.

  • Query Parameters:

  • Append data to the URL after a ? symbol (e.g., /products?category=electronics).

  • Used for optional, filter-like data that modifies the request.

Extra: is a way to pass data to a route that isn’t captured by either path or query parameters, we can pass any object is extra.

Note: extra is a common state for all routes, so it will contain only one state at a time.

@TypedGoRoute<ProductDetailsRoute>(path: '/details/:id')
class ProductDetailsRoute extends GoRouteData {
  // The variable name defined in the path is used as a path parameter.
  final String id; 

  // The variable name not defined in the path is used as a query parameter.
  final String code;

  // To use extra data, we have to set the variable name with $extra.
  final String $extra;

  const ProductDetailsRoute({required this.id, required this.code, required this.$extra});

  @override
  Widget build(BuildContext context, GoRouterState state) =>
      ProductDetails(pathParameterId: id, queryParameterCode:code, extraData: $extra);
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Code Generation

After defining the route, you need to generate the route list and extensions. To do this, you have to use build_runner.

Let’s start by adding a generated file part to your current file.

part 'routes.g.dart'; //part '<current-file>.g.dart';
Enter fullscreen mode Exit fullscreen mode

Now let’s run build_runner command,

dart run build_runner build --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

It will generate routes.g.dart file in your current file directory.

Step 4: GoRouter Initialization

Now you can pass generated $appRoutes on routes, and you can use generated location getter to get the exact route location.

final _router = GoRouter(
  initialLocation: HomeRoute().location, // location getter is generated.
  //$appRoutes is generated
  routes: $appRoutes,
  redirect: (context, state) { // Optional
    // You can manage redirection here by returning the route location.
    // Also you can prevent the user from navigating to the screen via the search URL.
    // Return null to prevent redirect.
  }
  errorBuilder: (context, e) => ErrorScreen(e), // Optional
  navigatorKey: rootNavigationKey, //Optional
);
Enter fullscreen mode Exit fullscreen mode

Step 5: Navigate to another screen

Now that we have our routes set up, let’s explore navigation methods
to navigate to other screens.

Go:
Replace the current stack of screens with the provided route destination.

await VerifyRoute(verificationId: id).go(context);
Enter fullscreen mode Exit fullscreen mode

Push:

Push a location onto the page stack.

await VerifyRoute(verificationId: id).push(context);

// Also you can catch value from push
final result = await VerifyRoute(verificationId: id).push(context);

Enter fullscreen mode Exit fullscreen mode

Push Replacement:

Replace the top-most page of the page stack with the given URL location.

await VerifyRoute(verificationId: id).pushReplacement(context);
Enter fullscreen mode Exit fullscreen mode

Replace:

Replace the topmost page of the stack with the given one, but treat it as the same page.

await VerifyRoute(verificationId: id).replace(context);
Enter fullscreen mode Exit fullscreen mode

That’s it, we’re done with navigation implementation. 👏

Now, let’s see how we can implement a shell route with type-safe navigation using go_router and go_router_builder.

Shell Route Navigation

Let’s explore how we can implement a shell route using a type-safe route.

First, to define a shell route, we need to create a class extending ShellRouteData and annotate it with @TypedShellRoute().

final GlobalKey<NavigatorState> shellNavigatorKey = GlobalKey<NavigatorState>();

@TypedShellRoute<DashboardShellRoute>(
  routes: <TypedRoute<RouteData>>[
    TypedGoRoute<HomeRoute>(path: '/'),
    TypedGoRoute<ProfileRoute>(path: '/profile'),
  ],
)
class DashboardShellRoute extends ShellRouteData {
  const DashboardShellRoute();

  static final GlobalKey<NavigatorState> $navigatorKey = shellNavigatorKey;

  @override
  Widget builder(BuildContext context, GoRouterState state, Widget navigator) {
    // In the navigator, we get the current tab widget.
    return MyDashboardScreen(child: navigator);
  }
}

class HomeRoute extends GoRouteData {
  @override
  Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}


class ProfileRoute extends GoRouteData {
  @override
  Widget build(BuildContext context, GoRouterState state) =>
      const ProfileScreen();
}
Enter fullscreen mode Exit fullscreen mode

In this code, we have created a class for dashboard(shell widget) widget by extending ShellRouteData and annotating it with TypedShellRoute.

Let’s explore this in more detail.

ShellRouteData: ShellRouteData: ShellRouteData is an abstract class that contains a builder method to build the shell.

class DashboardShellRoute extends ShellRouteData {
  @override
  Widget builder(BuildContext context, GoRouterState state, Widget navigator) {
    // In the navigator, we get the current tab widget.
    return MyDashboardScreen(child: navigator);
  }
}
Enter fullscreen mode Exit fullscreen mode

TypedShellRoute: TypedShellRoute annotation is used to define the shell route tree. It contains a routes list where we have to define our tab routes.

@TypedShellRoute<DashboardShellRoute>(
  routes: <TypedRoute<RouteData>>[
    TypedGoRoute<HomeRoute>(path: '/'),
    TypedGoRoute<ProfileRoute>(path: '/profile'),
  ],
)
Enter fullscreen mode Exit fullscreen mode

Let’s see how we can navigate between tabs in a shell route.

class MyDashboardScreen extends StatelessWidget {
  const MyDashboardScreen({required this.child, super.key});

  final Widget child;

  int getCurrentIndex(BuildContext context) {
    final String location = GoRouterState.of(context).uri.path;
    if (location.startsWith('/profile')) {
      return 1;
    }
    return 0;
  }

  @override
  Widget build(BuildContext context) {
    final int selectedIndex = getCurrentIndex(context);
    //Define your dashboard UI, such as a bottom bar, navigation rail, etc.
    return Scaffold(
      body: Row(
        children: <Widget>[
          NavigationRail(
            destinations: const <NavigationRailDestination>[
              NavigationRailDestination(
                icon: Icon(Icons.home),
                label: Text('Home'),
              ),
              NavigationRailDestination(
                icon: Icon(Icons.person),
                label: Text('Profile'),
              ),
            ],
            selectedIndex: selectedIndex,
            onDestinationSelected: (int index) {
              switch (index) {
                case 0:
                // Note: All navigation methods will be accessible after the code generation process.
                  HomeRoute().go(context);
                case 1:
                  ProfileRoute().go(context);
              }
            },
          ),
          const VerticalDivider(thickness: 1, width: 1),
          Expanded(child: child),
        ],
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

In this code, we have created a shell widget, and in the child parameter, we get the current tab widget.

We have managed tab navigation using an index. To obtain the current tab index, we retrieve the current path using GoRouterState.of(context).uri.path and compare it with the tab path.

That’s it, we’re done with shell route implementation. 👏

Tips

  • If your web URL isn’t updated on push, you can perform a go-route migration. See the migration guide for instructions.
  • You can define URL strategies to remove the ‘#’ symbol from URLs,See documentation.
  • If you want to manage your browser URL history, you can do it with neglect. See documentation.

Conclusion

By following the steps in this article, you can easily set up type-safe routes using go_router and go_router_builder.

This will make your app navigation more user-friendly and less prone to errors and enhance the navigation flow in apps that support web platforms.

Stay tuned for more insights on Flutter in our upcoming blog posts! 🚀📝

To read full blog on our platform, please visit this blog.

The post is originally published on canopas.com.


If you like what you read, be sure to hit 💖 button below! — as a writer it means the world!

I encourage you to share your thoughts in the comments section below. Your input not only enriches our content but also fuels our motivation to create more valuable and informative articles for you.

Happy coding! 👋

Top comments (0)