Understanding Flutter Navigation 2.0 with GoRouter

by Didin J. on Jun 09, 2025 Understanding Flutter Navigation 2.0 with GoRouter

Learn how to use Flutter's GoRouter for Navigation 2.0 with route guards, deep links, nested routes, and more in this step-by-step tutorial.

Navigation has always been a vital part of building multi-screen apps in Flutter. Traditionally, developers used `Navigator` and `MaterialPageRoute`, but this imperative approach soon revealed its limitations, especially when handling deep links, complex routing trees, or web navigation.

With Flutter Navigation 2.0, the framework embraced a more declarative approach to routing. However, the new API introduced a steep learning curve, with concepts such as `RouterDelegate`, `RouteInformationParser`, and `BackButtonDispatcher`.

Enter `go_router`: an intuitive and developer-friendly package built on top of Navigation 2.0. It provides a simplified interface while retaining the power and flexibility of the underlying API.

In this tutorial, we’ll explore how to build a robust navigation system using `go_router`, starting from simple routes to more advanced features like nested routing, route guards, and deep linking.


1. Preparation: Setting Up Flutter and go_router

Before diving into implementing go_router, let’s prepare your Flutter project. This section will guide you through setting up a fresh Flutter app using Android Studio, adding the necessary dependencies, and preparing the project for routing. Make sure you have installed the latest Flutter SDK https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.32.2-stable.zip.

1. Create a New Flutter Project

Open Android Studio and follow these steps:

1. Go to File → New → New Flutter Project

2. Select Flutter Application and click Next

3. Enter the following details:

  •    Project name: flutter_gorouter_demo
  •    Project location: Choose your directory
  •    Description: A demo of Flutter Navigation 2.0 using go_router

4. Make sure the Flutter SDK path is correctly set

5. Click Finish

Android Studio will generate a starter Flutter project for you.

2. Add go_router Dependency

Open pubspec.yaml and add the go_router package under dependencies:

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8
  go_router: ^14.1.3

Then run:

flutter pub get

3. Create Basic Screens

Let’s create a couple of screen files to test navigation:

lib/screens/home_page.dart

import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Will add routing here soon
          },
          child: const Text('Go to Profile'),
        ),
      ),
    );
  }
}

lib/screens/profile_page.dart

import 'package:flutter/material.dart';

class ProfilePage extends StatelessWidget {
  const ProfilePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: const Center(
        child: Text('Welcome to the Profile Page'),
      ),
    );
  }
}

Once this setup is done, you're ready to integrate go_router into your app.


2. What is Navigation 2.0?

Flutter's original navigation system—often called Navigation 1.0—relied on a stack-based, imperative approach. You pushed and popped routes with commands like Navigator.push(context, route) and Navigator.pop(context). While it was simple and effective for many apps, it lacked the flexibility to handle more complex navigation scenarios, such as:

  • Deep linking (handling URLs from external sources)
  • Web navigation with browser back/forward buttons
  • Nested navigation stacks (like tabs within tabs)
  • Programmatic route parsing and state restoration

To address these limitations, Flutter introduced Navigation 2.0, a declarative routing system inspired by modern web frameworks.

Core Concepts of Navigation 2.0

Navigation 2.0 is based on three key building blocks:

🔹 RouteInformationParser

This parses incoming route information (e.g., from a browser URL) into a data model your app can understand. Think of it as the bridge between the browser’s address bar and your app’s state.

🔹 RouterDelegate

This takes your app’s navigation state and builds the appropriate set of widgets to display. It’s where your main routing logic lives.

🔹 BackButtonDispatcher

Handles system back button events on Android or browser back navigation, passing them to your RouterDelegate.

These components give you fine-grained control over navigation behavior, which is powerful, but also verbose and complicated for many use cases.

Why Navigation 2.0 Can Be Challenging

While Navigation 2.0 is flexible and powerful, writing all the boilerplate manually can be painful:

  • You must manage your page stacks
  • URL parsing logic needs to be written explicitly
  • Guarding routes (e.g., authentication checks) takes extra effort
  • Nested routing and state restoration can quickly get messy

That’s where go_router comes in.


3. Why Use go_router?

To simplify the complexity of Flutter’s Navigation 2.0, the Flutter team and community introduced go_router — a declarative routing package built on top of the Navigation 2.0 API.

It provides a concise, easier-to-read syntax while still supporting all the advanced features you’d expect from a modern router: deep linking, redirection, route guards, nested navigation, and URL-based state.

Benefits of go_router

Here are some of the key reasons developers choose go_router:

Declarative Routing

Instead of pushing and popping manually, you define your app’s routes declaratively. This makes navigation state more predictable and maintainable.

final _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/profile',
      builder: (context, state) => const ProfilePage(),
    ),
  ],
);

Deep Linking & URL Sync

go_router makes it easy to sync your navigation with the browser’s address bar on Flutter Web. You can handle incoming links and restore navigation state effortlessly.

Route Guards

Need to protect certain routes behind authentication? go_router supports redirection and guards out of the box:

redirect: (BuildContext context, GoRouterState state) {
  final isLoggedIn = authService.isLoggedIn;
  final goingToLogin = state.location == '/login';

  if (!isLoggedIn && !goingToLogin) return '/login';
  if (isLoggedIn && goingToLogin) return '/';

  return null;
},

Nested Navigation

You can define nested routes for more complex UI flows (e.g., tabs with internal navigation).

URL Parameters & Query Support

Dynamic routes and parameters are easy to extract and use in your widgets.

GoRoute(
  path: '/user/:id',
  builder: (context, state) {
    final id = state.params['id'];
    return UserProfilePage(userId: id);
  },
)

Production-Ready Features

  • Async redirection
  • Error handling
  • Custom transitions
  • Shell routes for persistent UI (like a bottom navigation bar)

Go_router essentially gives you the power of Navigation 2.0 with the simplicity of Navigation 1.0, making it ideal for apps of all sizes.


4. Setting Up go_router in a Flutter Project

With your Flutter app ready and go_router installed, it’s time to configure it and build your navigation logic using the modern Flutter Navigation 2.0 approach.

Step 1: Create a GoRouter Configuration File

Create a new file named router.dart inside the lib directory:

lib/router.dart

Then, add the following content:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

import 'screens/home.dart';
import 'screens/profile_page.dart';

final GoRouter router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      name: 'home',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/profile',
      name: 'profile',
      builder: (context, state) => const ProfilePage(),
    ),
  ],
);

This creates a router instance with two simple routes: / for the Home page and /profile for the Profile page.

Step 2: Hook the Router into the App

Now open main.dart and replace the default MaterialApp with MaterialApp.router, which is required for using go_router.

lib/main.dart

import 'package:flutter/material.dart';
import 'router.dart'; // Import the router we just created

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'GoRouter Demo',
      routerConfig: router,
      debugShowCheckedModeBanner: false,
    );
  }
}

Step 3: Implement Navigation with GoRouter

Open the home_page.dart file and update the onPressed method to use context.go() for navigation:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            context.go('/profile'); // ✅ Directly call the navigation
          },
          child: const Text('Go to Profile'),
        ),
      ),
    );
  }
}

Alternatively, you can use the named route syntax:

context.goNamed('profile');

That’s it! You’ve now successfully set up go_router with Navigation 2.0 in your Flutter project.

Understanding Flutter Navigation 2.0 with GoRouter - basic navigation


5. Nested Routes and Shell Navigation

As your Flutter app grows in complexity, you’ll often want to nest routes or provide a persistent layout (like a bottom navigation bar) across different pages. This is where nested routes and ShellRoute come in.

What Are Nested Routes?

Nested routes allow you to define child pages under a parent route, keeping your navigation structure more organized. This is useful when you want to group routes under a shared layout or logic.

Introducing ShellRoute

In GoRouter, a ShellRoute provides a shared UI scaffold (like a bottom navigation bar) that wraps around multiple child routes. Each child route then displays its content inside the shell.

Let’s implement a basic ShellRoute example with three tabs: Home, Search, and Profile.

Step-by-Step: Shell Navigation with Bottom Navigation Bar

1. Create the Tab Screens

Add a simple screen widget: lib/screens/search_page.dart.

import 'package:flutter/material.dart';

class SearchPage extends StatelessWidget {
  const SearchPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(child: Text('Search Page'));
  }
}

2. Create the Shell Layout with Bottom Navigation

Create a ShellScaffold that will hold the navigation bar and display child routes.

layout/shell_scaffold.dart:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class ShellScaffold extends StatelessWidget {
  final Widget child;

  const ShellScaffold({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    final location = GoRouterState.of(context).uri.toString();

    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _locationToTabIndex(location),
        onTap: (index) {
          switch (index) {
            case 0:
              context.go('/home');
              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'),
        ],
      ),
    );
  }

  int _locationToTabIndex(String location) {
    if (location.startsWith('/search')) return 1;
    if (location.startsWith('/profile')) return 2;
    return 0;
  }
}

3. Update Your Router to Use ShellRoute

Now wire everything up in your router.dart:

import 'package:go_router/go_router.dart';

import 'screens/home.dart';
import 'screens/search_page.dart';
import 'screens/profile_page.dart';
import 'layout/shell_scaffold.dart';

final GoRouter router = GoRouter(
  initialLocation: '/home',
  routes: [
    ShellRoute(
      builder: (context, state, child) => ShellScaffold(child: child),
      routes: [
        GoRoute(
          path: '/home',
          name: 'home',
          builder: (context, state) => const HomePage(),
        ),
        GoRoute(
          path: '/search',
          name: 'search',
          builder: (context, state) => const SearchPage(),
        ),
        GoRoute(
          path: '/profile',
          name: 'profile',
          builder: (context, state) => const ProfilePage(),
        ),
      ],
    ),
  ],
);

What You Get

  • A bottom navigation bar shared across multiple pages
  • Pages rendered inside the shell layout
  • Clean, structured, and scalable routing setup

Understanding Flutter Navigation 2.0 with GoRouter - tab

Pro Tip

You can nest more levels or even define different ShellRoutes for authenticated and unauthenticated users. It's a great way to control layouts and state visibility.


6. Route Parameters and Dynamic Linking in GoRouter

In real-world apps, you'll often need to pass parameters through routes—for example, navigating to a user profile page by ID. GoRouter makes this easy with path parameters, query parameters, and support for deep linking.

1. Defining Route Parameters

To define a route with a parameter, use a : prefix in the path.

GoRoute(
  path: '/user/:id',
  name: 'userProfile',
  builder: (context, state) {
    final userId = state.pathParameters['id'];
    return UserProfilePage(userId: userId!);
  },
),

2. Navigating with Parameters

When navigating, use the context.go() or context.push() method with string interpolation or named routes.

// Using path
context.go('/user/123');

// Using named route
context.goNamed('userProfile', pathParameters: {'id': '123'});

3. Accessing Parameters in the Page

Inside the UserProfilePage, accept the parameter through the constructor:

import 'package:flutter/material.dart';

class UserProfilePage extends StatelessWidget {
  final String userId;

  const UserProfilePage({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('User Profile')),
      body: Center(child: Text('User ID: $userId')),
    );
  }
}

4. Using Query Parameters

Query parameters are supported using state.uri.queryParameters.

GoRoute(
  path: '/search',
  name: 'search',
  builder: (context, state) {
    final query = state.uri.queryParameters['q'] ?? '';
    return SearchPage(query: query);
  },
);

To navigate with a query parameter:

context.go('/search?q=flutter');

Or:

context.goNamed('search', queryParameters: {'q': 'flutter'});

5. Deep Linking Support

Flutter with GoRouter also supports deep links. If your app is launched with a URL like myapp://user/123, GoRouter can match and render the correct page automatically, as long as that path is defined in your route table.

To test it, use a tool like adb or configure your Android and iOS platforms to handle deep links using URL schemes or app links.


7. Route Guards and Redirects in GoRouter

In many apps, navigation must respect authentication, authorization, or user-specific conditions. GoRouter supports this via redirects and route guards, which are flexible mechanisms to control navigation based on app state.

1. Basic Redirect

You can redirect from one route to another by using the redirect property in a GoRoute.

Here’s a basic example that redirects the root/route to /home:

GoRoute(
  path: '/',
  redirect: (_, __) => '/home',
),

2. Redirecting Based on App State

A more powerful use case is redirecting based on authentication status. For this, you can use a redirect in the GoRouter constructor and check state from a shared object (like a provider or service).

Here’s an example using a simple AuthService:

final AuthService authService = AuthService();

final GoRouter router = GoRouter(
  initialLocation: '/',
  refreshListenable: authService, // Notifies GoRouter to reevaluate redirect
  redirect: (context, state) {
    final isLoggedIn = authService.isLoggedIn;
    final isLoggingIn = state.uri.path == '/login';

    if (!isLoggedIn && !isLoggingIn) return '/login';
    if (isLoggedIn && isLoggingIn) return '/home';

    return null; // no redirect
  },
  routes: [
    GoRoute(
      path: '/login',
      name: 'login',
      builder: (context, state) => const LoginPage(),
    ),
    GoRoute(
      path: '/home',
      name: 'home',
      builder: (context, state) => const HomePage(),
    ),
  ],
);

3. Using Route Guards (Redirect with Route-level auth)

If you want to protect specific routes, you can use the redirect property at the route level:

GoRoute(
  path: '/profile',
  name: 'profile',
  redirect: (context, state) {
    return authService.isLoggedIn ? null : '/login';
  },
  builder: (context, state) => const ProfilePage(),
),

This ensures only authenticated users can access the profile page.

4. Example AuthService (for demo)

import 'package:flutter/cupertino.dart';

class AuthService extends ChangeNotifier {
  bool _loggedIn = false;

  bool get isLoggedIn => _loggedIn;

  void login() {
    _loggedIn = true;
    notifyListeners();
  }

  void logout() {
    _loggedIn = false;
    notifyListeners();
  }
}

Summary

  • Use a global redirect for app-wide authentication handling.
  • Use per-route redirect for fine-grained access control.
  • Combine with a ChangeNotifier to update the redirect logic reactively.


8. Error Handling and 404 Pages in GoRouter

Good navigation includes proper error handling, especially when a user lands on an undefined route. GoRouter makes this easy by allowing you to define a default error page using the errorBuilder.

1. Define a Custom Error Page

Let’s first create a simple error screen (screens/error_page.dart):

import 'package:flutter/material.dart';

class ErrorPage extends StatelessWidget {
  final Exception? error;

  const ErrorPage({super.key, this.error});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page Not Found')),
      body: Center(
        child: Text(
          'Oops! ${error?.toString() ?? 'Unknown error occurred.'}',
          style: const TextStyle(fontSize: 16),
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

2. Add errorBuilder to your GoRouter Configuration

In your router.dart, update the GoRouter to include the errorBuilder:

import 'screens/error_page.dart'; // Import the ErrorPage

final GoRouter router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      name: 'home',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/profile',
      name: 'profile',
      builder: (context, state) => const ProfilePage(),
    ),
  ],
  errorBuilder: (context, state) => ErrorPage(error: state.error),
);

3. Test an Invalid Route

Now try navigating to an undefined route:

context.go('/non-existent-route');

You should see the ErrorPage with the appropriate message.

Bonus: Custom 404 Design

Feel free to enhance your ErrorPage with illustrations, icons, or navigation buttons to guide the user back home.

ElevatedButton(
  onPressed: () => context.go('/'),
  child: const Text('Go Back Home'),
),

Summary

  • Use errorBuilder to define a fallback page for unknown routes or errors.
  • Pass state.error to help debug or inform the user.
  • Combine with a user-friendly UI to improve the experience.


9. Conclusion and Next Steps

In this tutorial, we've taken a comprehensive journey through Flutter Navigation 2.0 using the powerful and flexible go_router package. Here's a quick recap of what you've learned:

  • What GoRouter is and how it improves upon traditional navigation.
  • Setting Up GoRouter in a Flutter project.
  • Defining Simple Routes and navigating between them.
  • Implementing Nested Routes and using shell navigation for complex layouts.
  • Using Route Parameters and enabling deep/dynamic linking.
  • Adding Route Guards for authenticated access.
  • Handling Errors and Unknown Routes with a clean fallback UI.

What’s Next?

To go further with Flutter navigation using GoRouter, consider:

  • 🔒 Integrating authentication flows with packages like Firebase Auth or Supabase.
  • 🌍 Adding localization and route i18n for multi-language support.
  • 🧪 Writing unit and widget tests for route behaviors.
  • 🎯 Optimizing for web and deep links using GoRouter’s built-in URL strategies.
  • 🧱 Using custom transition animations for page routing.

GoRouter brings clarity and power to Flutter’s navigation system. Whether you're building a small app or a multi-screen enterprise solution, mastering this approach ensures a scalable and maintainable navigation architecture.

You can get the full source code on our GitHub.

That's just the basics. If you need more deep learning about Flutter, Dart, or related, you can take the following cheap course:

Thanks!