Stateful Shell Navigation with Go Router: The Ultimate Guide

Building a great system warrants a greater Navigation System.

Stateful Shell Navigation with Go Router: The Ultimate Guide
Building a great system warrants a greater Navigation System.

The navigation system is the core of a mobile application. In Flutter, you get native solutions like Navigator 1.0 APIs and the improved version, Navigator 2.0 APIs for navigation and a Router (for better handling navigation), and packages like GoRouter, Beamer, or Autoroute, and many more.

This will be a 2 part series because I can’t explain so much without wanting to take breaks so often. There is so much to go.

Note: This is a long series and each article will go into detail. You can skip the gory details and can still build an example if you just want to try it out. Here is the repository link to just try out the example

The series:

  1. Stateful Shell Navigation with Go Router
  2. Widget tree and Navigation Stack: Deep into Go Router and Navigator 2.0

We are going to be building the simplest application in Flutter. So simple, that we are only going to use the Counter Application we get when we create a new Flutter Project. I promise. Where and and how we will make it complex and break things up, is not on me.

Once we learn how a Navigator and Router work, Stateful Shell Navigation will be a piece of cake. So, let’s start with some basics.

What is a Navigator and what is a router?

You must have seen a Television and it’s remote. This is the same relationship. The navigator is the television and the remote is the Router (relaying information from you to the TV).

Navigator is a widget that uses an Overlay to manage all our “Screens” (Routes) and overlay is just a fancier stack implementation that allows Navigator to push and pop screens. Navigator offers 2 kinds of APIs:

The declarative way: Navigator.pages API, which means you provide Navigator with a list of pages and build them into Routes and stack them in the way you provided your list.

final List<Page<dynamic>> pages;

The imperative way: Navigator.push and Navigator.pop API, which means you just have to call Navigator.push(<RoutePath>) and Navigator will add a page (route) to the Navigation Stack.

Router

A router is a widget that relays information to the Navigator. Whatever information the router provides to the navigator, the navigator builds the stack in that order.

Flutter documentation says:

Flutter applications with advanced navigation and routing requirements (such as a web app that uses direct links to each screen, or an app with multiple Navigator widgets) should use a routing package such as go_router that can parse the route path and configure the Navigator whenever the app receives a new deep link.
To use the Router, switch to the router constructor on MaterialApp or CupertinoApp and provide it with a Router configuration. Routing packages, such as go_router, typically provides a configuration for you.

And, our very own GoRouter is a wrapper around the Router that uses the Router API to provide a convenient, URL-based API for navigating between different screens.

Now that we are done with the basics, let’s start with understanding Stateful Shell Navigation with StatefulShellRoute, ShellNavigationContainerBuilder and StatefulShellBranch.

Stateful Shell Navigation with Go Router

We understand the basics of Navigator.push and Navigator.pop
GoRouter provides us with powerful APIs such as traversing to pages via path and names with APIs like:

GoRouter.of(context).push 
GoRouter.of(context).pushNamed 
GoRouter.of(context).go 
GoRouter.of(context).goNamed 
.....

Another use case that comes to mind is persistent bottom navigation and deep linking to either tabs or nested pages inside the tab. That seems like most of the apps nowadays. A persistent bottom navigation bar, a Settings and a Profile page. So what is Stateful Shell and how does it help?
A route that displays a UI shell with separate Navigators for its sub-routes.

Flutter documentation says:

A route that displays a UI shell with separate Navigators for its sub-routes.
Similar to ShellRoute, this route class places its sub-route on a different Navigator than the root Navigator. However, this route class differs in that it creates separate Navigators for each of its nested branches (i.e. parallel navigation trees), making it possible to build an app with stateful nested navigation. This is convenient when for instance implementing a UI with a BottomNavigationBar, with a persistent navigation state for each tab.

Didn’t get it the first time reading? Let me help you.

We know that there is a navigator in play that handles the stack of pages. But what if each bottom navigation screen, itself is controlled via different Navigators?

What does this mean?

  1. Since we know Navigator manages a stack of entries (pages or routes), having a separate navigator for each tab will allow us to navigate via that scoped navigator. And we achieve stateful nested navigation. Meaning, that each tab can hold a list of pages that the navigator manages and remembers.
  2. Since we are using a router, we can navigate to a page inside the Nested Tab via a deep link.

Let’s see how this works.

Create an empty Flutter project and name it whatever you like:

flutter create navigation_demo

And let’s add the dependencies required for this demo:

flutter pub add go_router

Also, let’s cut and paste the MyHomePage Widget into a new directory named UI. Or whatever you want. And rename it to DemoPage.

import 'dart:math'; 
 
import 'package:flutter/material.dart'; 
 
class DemoPage extends StatefulWidget { 
  const DemoPage({super.key, required this.title, this.child}); 
 
  // This widget is the home page of your application. It is stateful, meaning 
  // that it has a State object (defined below) that contains fields that affect 
  // how it looks. 
 
  // This class is the configuration for the state. It holds the values (in this 
  // case the title) provided by the parent (in this case the App widget) and 
  // used by the build method of the State. Fields in a Widget subclass are 
  // always marked "final". 
 
  final String title; 
  final Widget? child; 
 
  @override 
  State<DemoPage> createState() => _DemoPageState(); 
} 
 
class _DemoPageState extends State<DemoPage> { 
  int _counter = 0; 
 
  late Color _primaryColor; 
 
  @override 
  void initState() { 
    // generate a random color with Math.Random 
    _primaryColor = 
        Color((Random().nextDouble() * 0xFFFFFF).toInt() << 0).withOpacity(1.0); 
    super.initState(); 
  } 
 
  void _incrementCounter() { 
    setState(() { 
      // This call to setState tells the Flutter framework that something has 
      // changed in this State, which causes it to rerun the build method below 
      // so that the display can reflect the updated values. If we changed 
      // _counter without calling setState(), then the build method would not be 
      // called again, and so nothing would appear to happen. 
      _counter++; 
    }); 
  } 
 
  @override 
  Widget build(BuildContext context) { 
    // This method is rerun every time setState is called, for instance as done 
    // by the _incrementCounter method above. 
    // 
    // The Flutter framework has been optimized to make rerunning build methods 
    // fast, so that you can just rebuild anything that needs updating rather 
    // than having to individually change instances of widgets. 
    return Scaffold( 
      appBar: AppBar( 
        // TRY THIS: Try changing the color here to a specific color (to 
        // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar 
        // change color while the other colors stay the same. 
        backgroundColor: _primaryColor, 
        // Here we take the value from the MyHomePage object that was created by 
        // the App.build method, and use it to set our appbar title. 
        title: Text(widget.title), 
      ), 
      body: Center( 
        // Center is a layout widget. It takes a single child and positions it 
        // in the middle of the parent. 
        child: Column( 
          // Column is also a layout widget. It takes a list of children and 
          // arranges them vertically. By default, it sizes itself to fit its 
          // children horizontally, and tries to be as tall as its parent. 
          // 
          // Column has various properties to control how it sizes itself and 
          // how it positions its children. Here we use mainAxisAlignment to 
          // center the children vertically; the main axis here is the vertical 
          // axis because Columns are vertical (the cross axis would be 
          // horizontal). 
          // 
          // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" 
          // action in the IDE, or press "p" in the console), to see the 
          // wireframe for each widget. 
          mainAxisAlignment: MainAxisAlignment.center, 
          children: <Widget>[ 
            const Text( 
              'You have pushed the button this many times:', 
            ), 
            Text( 
              '$_counter', 
              style: Theme.of(context).textTheme.headlineMedium, 
            ), 
            const SizedBox( 
              height: 20, 
            ), 
            if (widget.child != null) widget.child!, 
          ], 
        ), 
      ), 
      floatingActionButton: FloatingActionButton( 
        heroTag: UniqueKey(), 
        onPressed: _incrementCounter, 
        tooltip: 'Increment', 
        child: const Icon(Icons.add), 
      ), // This trailing comma makes auto-formatting nicer for build methods. 
    ); 
  } 
}

Since we don’t want to use only the Navigator, but also the Router, we would have to convert the MaterialApp constructor to a MaterialApp.router one and add the routerConfig. This is where GoRouter comes in. GoRouter provides a nice little wrapper over the Router and uses Navigator to provide URL-based navigation, making your app deep-linkable.

Now let’s create a core directory and add a router class, router.dart

Since we might want to pass some data from one screen to the other, we should create an object for the same at once.

class AppRouteInformation<T> { 
  final T? data; 
  final bool maintainState; 
 
  AppRouteInformation({ 
    this.data, 
    this.maintainState = true, 
  }); 
}

Let’s also create our AppRouter, a singleton, which will handle everything related to navigation.

import 'package:flutter/material.dart'; 
import 'package:go_router/go_router.dart'; 
import 'package:navigation_demo/ui/pages.dart'; 
 
final navigatorKey = GlobalKey<NavigatorState>(); 
 
class AppRouteInformation<T> { 
  final T? data; 
  final bool maintainState; 
 
  AppRouteInformation({ 
    this.data, 
    this.maintainState = true, 
  }); 
} 
 
class AppRouter { 
  // create a singleton 
  static final AppRouter instance = AppRouter._internal(); 
 
  // create a private constructor 
  AppRouter._internal(); 
 
  // create a GoRouter instance 
  GoRouter? _router; 
 
  GoRouter get router { 
    if (_router == null) { 
      _initRouter(); 
      return _router!; 
    } 
    return _router!; 
  } 
 
  void go<T>(String path, {T? data}) { 
    _router?.go( 
      path, 
      extra: AppRouteInformation<T>( 
        data: data, 
      ), 
    ); 
  } 
 
  Future<T?>? push<T>(String path, {T? data}) { 
    return _router?.push<T>( 
      path, 
      extra: AppRouteInformation<T>( 
        data: data, 
      ), 
    ); 
  } 
 
  void _initRouter() { 
    _router = GoRouter( 
      initialLocation: '/login', 
      debugLogDiagnostics: true, 
      navigatorKey: navigatorKey, 
      routes: [ 
        GoRoute( 
          path: '/login', 
          builder: (context, state) => const DemoPage(title: 'Login'), 
        ), 
      ], 
    ); 
  } 
 
  Page buildPage(Widget child) { 
    return MaterialPage( 
      child: child, 
    ); 
  } 
}

And our main.dart looks like this:

import 'package:flutter/material.dart'; 
import 'package:navigation_demo/ui/router.dart'; 
 
void main() { 
  runApp(const MyApp()); 
} 
 
class MyApp extends StatelessWidget { 
  const MyApp({super.key}); 
 
  // This widget is the root of your application. 
  @override 
  Widget build(BuildContext context) { 
    return MaterialApp.router( 
      title: 'Flutter Demo', 
      theme: ThemeData( 
        // This is the theme of your application. 
        // 
        // TRY THIS: Try running your application with "flutter run". You'll see 
        // the application has a purple toolbar. Then, without quitting the app, 
        // try changing the seedColor in the colorScheme below to Colors.green 
        // and then invoke "hot reload" (save your changes or press the "hot 
        // reload" button in a Flutter-supported IDE, or press "r" if you used 
        // the command line to start the app). 
        // 
        // Notice that the counter didn't reset back to zero; the application 
        // state is not lost during the reload. To reset the state, use hot 
        // restart instead. 
        // 
        // This works for code too, not just values: Most code changes can be 
        // tested with just a hot reload. 
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), 
        useMaterial3: true, 
      ), 
      routerConfig: AppRouter.instance.router, 
    ); 
  } 
}

And when we rerun our app, we get this in our console:

[GoRouter] Full paths for routes:
└─/login (DemoPage)

We have finally created a GoRouter-based app. This is standard stuff.
Now that we want to add a BottomNavigationBar in our app, we can use the StatefulShellRoute.indexedStack constructor and add three branches (for 3 tabs in your app).

_router = GoRouter( 
  initialLocation: '/login', 
  debugLogDiagnostics: true, 
  navigatorKey: navigatorKey, 
  routes: [ 
    StatefulShellRoute.indexedStack( 
      builder: (context, state, navigationShell) => MyHomePage( 
        navigationShell: navigationShell, 
      ), 
      branches: [ 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/', 
              builder: (context, state) => const DemoPage( 
                title: 'Home', 
              ), 
            ), 
          ], 
        ), 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/search', 
              builder: (context, state) => const DemoPage(title: 'Search'), 
            ), 
          ], 
        ), 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/profile', 
              builder: (context, state) => const DemoPage(title: 'Profile'), 
            ), 
          ], 
        ), 
      ], 
    ), 
    GoRoute( 
      path: '/login', 
      builder: (context, state) => const DemoPage(title: 'Login'), 
    ), 
  ], 
);

The builder method builds the UI shell around this widget. MyHomePage widget takes in a navigationShell as an argument which is a widget responsible for managing the nested navigation for the matching sub-routes. You can use the navigation shell to switch between the branches (tabs).

A StatefulShellBranch is an immutable class representing a branch in a stateful navigation tree. Each branch gets its unique Navigator to manage the route stack. Each route gets placed onto this Navigator instead of the root Navigator. You can provide an initial location to a StatefulShellBranch but if you don’t, the first descendant’s location will be used.

import 'package:flutter/material.dart'; 
import 'package:go_router/go_router.dart'; 
import 'package:navigation_demo/ui/bottom_navigation_widget.dart'; 
 
class MyHomePage extends StatefulWidget { 
  const MyHomePage({ 
    super.key, 
    required this.navigationShell, 
  }); 
 
  final StatefulNavigationShell navigationShell; 
 
  @override 
  State<MyHomePage> createState() => _MyHomePageState(); 
} 
 
class _MyHomePageState extends State<MyHomePage> { 
  @override 
  Widget build(BuildContext context) { 
    return Scaffold( 
      bottomNavigationBar: BottomNavigationWidget( 
        currentIndex: widget.navigationShell.currentIndex, 
        onTap: _switchBranch, 
      ), 
      body: widget.navigationShel, 
    ); 
  } 
 
  void _switchBranch(int index) { 
    widget.navigationShell.goBranch( 
      index, 
      // A common pattern when using bottom navigation bars is to support 
      // navigating to the initial location when tapping the item that is 
      // already active. This example demonstrates how to support this behavior, 
      // using the initialLocation parameter of goBranch. 
      initialLocation: index == widget.navigationShell.currentIndex, 
    ); 
  } 
}

And a bottom navigation widget to go with it. So when you tap on the bottom navigation tabs, you can switch between the routes defined in the StatefulShellRoute. When you call the goBranch method with the index, GoRouter switches the navigator to the one that is being used by that branch and navigates to the last location of the StatefulShellBrach, meaning, if you have already navigated to some page in home, say, home/dial, so when you switch to the home branch, it will still be home/dial. If you want to go back to the first route of the branch, you can provide an initialLocation

import 'package:flutter/material.dart'; 
 
class BottomNavigationWidget extends StatelessWidget { 
  const BottomNavigationWidget({ 
    super.key, 
    required this.currentIndex, 
    required this.onTap, 
  }); 
 
  final int currentIndex; 
  final Function(int) onTap; 
 
  @override 
  Widget build(BuildContext context) { 
    return BottomNavigationBar( 
      currentIndex: currentIndex, 
      items: const [ 
        BottomNavigationBarItem( 
          icon: Icon(Icons.home), 
          label: 'Home', 
        ), 
        BottomNavigationBarItem( 
          icon: Icon(Icons.search), 
          label: 'Search', 
        ), 
        BottomNavigationBarItem( 
          icon: Icon(Icons.verified_user), 
          label: 'Profile', 
        ), 
      ], 
      onTap: onTap, 
    ); 
  } 
}

When you run the application now, you will see this on the console:

[GoRouter] Full paths for routes:
├─ (ShellRoute)
│ ├─/ (DemoPage)
│ ├─/search (DemoPage)
│ └─/profile (DemoPage)
└─/login (DemoPage)

One interesting thing to note here is when you open the Flutter Dev Tools and look at the Widget Tree, there is only one DemoPage instance at the home branch and only empty SizedBoxes as other branches’ children if they haven’t been visited once. Once you tap on the second tab, the second page is instantiated. And so on.

Let’s say your Web URLs are:

  1. /login
  2. /home
  3. /search
  4. /profile
  5. /users/id

And your UI only have 3 tabs, say home, search and profile as our example. You would add Home, Search and Profile as your StatefulShellBranch in StatefulShellRoute. You would add Login as a GoRoute in the outer tree and you would add Users as a GoRoute too in the outer tree.

Now what if you get a deep link, when the app is terminated, that traverses to /users/32? Should work, right? Yes, it does, but now this route is in the navigation stack at the root of the tree and if you press, the system back button, your app will close. Now you would want it to open like /home/users/32 so that when you press the back button, you can go to home page.

If you would like to test out our little theory, let’s set the initialLocation in our GoRouter Config to this and run the app.

_router = GoRouter( 
      initialLocation: '/users/32', 
      debugLogDiagnostics: true, 
      navigatorKey: navigatorKey, 
      routes: [ 
        StatefulShellRoute.indexedStack( 
          builder: (context, state, navigationShell) => MyHomePage( 
            navigationShell: navigationShell, 
          ), 
          branches: [ 
            StatefulShellBranch( 
              routes: [ 
                GoRoute( 
                  path: '/', 
                  builder: (context, state) => const DemoPage( 
                    title: 'Home', 
                  ), 
                ), 
              ], 
            ), 
            StatefulShellBranch( 
              routes: [ 
                GoRoute( 
                  path: '/search', 
                  builder: (context, state) => const DemoPage(title: 'Search'), 
                ), 
              ], 
            ), 
            StatefulShellBranch( 
              routes: [ 
                GoRoute( 
                  path: '/profile', 
                  builder: (context, state) => const DemoPage(title: 'Profile'), 
                ), 
              ], 
            ), 
          ], 
        ), 
        GoRoute( 
          path: '/login', 
          builder: (context, state) => DemoPage( 
            title: 'Login', 
            child: ElevatedButton( 
              onPressed: () { 
                AppRouter.instance.go('/'); 
              }, 
              child: const Text('Login'), 
            ), 
          ), 
        ), 
        GoRoute( 
          path: '/users/:id', 
          builder: (context, state) => DemoPage(title: 'User with ID: ${state.pathParameters['id']}'), 
        ), 
      ], 
    );

We get this output on running the app.

[GoRouter] Full paths for routes:
├─ (ShellRoute)
│ ├─/ (DemoPage)
│ ├─/search (DemoPage)
│ └─/profile (DemoPage)
├─/login (DemoPage)
└─/users/:id (DemoPage)

On pressing the back button, our app closes.

Now when we configure our router like this, we get this behaviour as would want it.

_router = GoRouter( 
  initialLocation: '/users/32', 
  debugLogDiagnostics: true, 
  navigatorKey: navigatorKey, 
  routes: [ 
    StatefulShellRoute.indexedStack( 
      builder: (context, state, navigationShell) => MyHomePage( 
        navigationShell: navigationShell, 
      ), 
      branches: [ 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/', 
              builder: (context, state) => const DemoPage( 
                title: 'Home', 
              ), 
              routes: [ 
                GoRoute( 
                  path: 'users/:id', 
                  builder: (context, state) => DemoPage(title: 'User with ID: ${state.pathParameters['id']}'), 
                ), 
              ], 
            ), 
          ], 
        ), 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/search', 
              builder: (context, state) => const DemoPage(title: 'Search'), 
            ), 
          ], 
        ), 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/profile', 
              builder: (context, state) => const DemoPage(title: 'Profile'), 
            ), 
          ], 
        ), 
      ], 
    ), 
    GoRoute( 
      path: '/login', 
      builder: (context, state) => DemoPage( 
        title: 'Login', 
        child: ElevatedButton( 
          onPressed: () { 
            AppRouter.instance.go('/'); 
          }, 
          child: const Text('Login'), 
        ), 
      ), 
    ), 
    GoRoute( 
      path: '/users/:id', 
      redirect: (context, state) => '/home/users/:id', 
    ), 
  ], 
);

When we run the app, we get this output.

[GoRouter] Full paths for routes:
├─ (ShellRoute)
│ ├─/ (DemoPage)
│ │ └─/users/:id (DemoPage)
│ ├─/search (DemoPage)
│ └─/profile (DemoPage)
├─/login (DemoPage)
└─/users/:id

And the response:

A use case for cyclic traversal

If we consider the initial Router Config for our deep link problem, and if your app has elements on the Users Screen that traverse back to your home page like this:

_router = GoRouter( 
  initialLocation: '/', 
  debugLogDiagnostics: true, 
  navigatorKey: navigatorKey, 
  routes: [ 
    StatefulShellRoute.indexedStack( 
      builder: (context, state, navigationShell) => MyHomePage( 
        navigationShell: navigationShell, 
      ), 
      branches: [ 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/', 
              builder: (context, state) => DemoPage( 
                title: 'Home', 
                child: ElevatedButton( 
                  onPressed: () { 
                    AppRouter.instance.push('/users/32'); 
                  }, 
                  child: const Text('Go User'), 
                ), 
              ), 
            ), 
          ], 
        ), 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/search', 
              builder: (context, state) => const DemoPage(title: 'Search'), 
            ), 
          ], 
        ), 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/profile', 
              builder: (context, state) => const DemoPage(title: 'Profile'), 
            ), 
          ], 
        ), 
      ], 
    ), 
    GoRoute( 
      path: '/login', 
      builder: (context, state) => DemoPage( 
        title: 'Login', 
        child: ElevatedButton( 
          onPressed: () { 
            AppRouter.instance.go('/'); 
          }, 
          child: const Text('Login'), 
        ), 
      ), 
    ), 
    GoRoute( 
      path: '/users/:id', 
      builder: (context, state) => DemoPage( 
        title: 'User with ID: ${state.pathParameters['id']}', 
        child: ElevatedButton( 
          onPressed: () { 
            AppRouter.instance.push('/profile'); 
          }, 
          child: const Text('Push Profile'), 
        ), 
      ), 
    ), 
  ], 
);

If you perform a push, you will get this error.

This is because when Navigator is informed to push /profile from /users/32, Navigator checks for duplicate page keys, and checks if the stack already contains the same page.key

So to avoid this situation, the same solution works from the previous case:

_router = GoRouter( 
  initialLocation: '/', 
  debugLogDiagnostics: true, 
  navigatorKey: navigatorKey, 
  routes: [ 
    StatefulShellRoute.indexedStack( 
      builder: (context, state, navigationShell) => MyHomePage( 
        navigationShell: navigationShell, 
      ), 
      branches: [ 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/', 
              builder: (context, state) => DemoPage( 
                title: 'Home', 
                child: ElevatedButton( 
                  onPressed: () { 
                    AppRouter.instance.push('/users/32'); 
                  }, 
                  child: const Text('Push Profile'), 
                ), 
              ), 
              routes: [ 
                GoRoute( 
                  path: 'users/:id', 
                  builder: (context, state) => DemoPage( 
                    title: 'User with ID: ${state.pathParameters['id']}', 
                    child: ElevatedButton( 
                      onPressed: () { 
                        AppRouter.instance.push('/profile'); 
                      }, 
                      child: const Text('Push Profile'), 
                    ), 
                  ), 
                ), 
              ], 
            ), 
          ], 
        ), 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/search', 
              builder: (context, state) => const DemoPage(title: 'Search'), 
            ), 
          ], 
        ), 
        StatefulShellBranch( 
          routes: [ 
            GoRoute( 
              path: '/profile', 
              builder: (context, state) => const DemoPage(title: 'Profile'), 
            ), 
          ], 
        ), 
      ], 
    ), 
    GoRoute( 
      path: '/login', 
      builder: (context, state) => DemoPage( 
        title: 'Login', 
        child: ElevatedButton( 
          onPressed: () { 
            AppRouter.instance.go('/'); 
          }, 
          child: const Text('Login'), 
        ), 
      ), 
    ), 
    GoRoute( 
      path: '/users/:id', 
      redirect: (context, state) => '/home/users/:id', 
    ), 
  ], 
);

This is it for this article. Hope you liked it. I will be posting another one soon. Stay tuned. You can always appreciate the work like the GIF below ;)