← Back to Articles

Flutter Navigation 2.0 and GoRouter: Modern Routing Made Simple

Flutter Navigation 2.0 and GoRouter: Modern Routing Made Simple

Flutter Navigation 2.0 and GoRouter: Modern Routing Made Simple

If you've been building Flutter apps, you've probably used the classic Navigator.push() and Navigator.pop() methods. They work great for simple apps, but as your app grows, you'll find yourself needing more control over navigation—deep linking, URL handling, nested routes, and better state management. That's where Flutter Navigation 2.0 and GoRouter come in.

In this article, we'll explore why modern navigation matters, how Navigation 2.0 works under the hood, and how GoRouter makes it all much simpler. By the end, you'll understand how to build navigation that scales with your app.

Why Navigation 2.0?

Traditional Flutter navigation (Navigator 1.0) treats navigation as a stack of routes. You push routes onto the stack and pop them off. It's simple and works well, but it has limitations:

  • No built-in URL support for web
  • Difficult to handle deep links properly
  • Hard to manage complex navigation flows
  • No declarative routing configuration

Navigation 2.0 solves these problems by making navigation declarative and URL-based. Instead of imperatively pushing routes, you declare what routes exist and their relationships, then update the current route based on your app's state.

However, Navigation 2.0 can be verbose and complex. That's where GoRouter shines—it provides a clean, simple API on top of Navigation 2.0 that handles all the complexity for you.

Understanding the Basics

Before diving into GoRouter, let's understand the core concepts of Navigation 2.0:

  • Route Information: Represents the current route state (URL, parameters, etc.)
  • Route Configuration: Defines all possible routes in your app
  • Route Parser: Converts URLs to route information and vice versa
  • Router Delegate: Manages the navigation stack based on route information

GoRouter simplifies this by providing sensible defaults for all these components, so you can focus on defining your routes rather than implementing the infrastructure.

Navigation 2.0 Architecture

URL /products/123 Route Parser Converts URL Route Info State Object Router Delegate Manages Stack Route Config All Routes Widget Tree UI Rendered

Getting Started with GoRouter

First, add GoRouter to your pubspec.yaml:


dependencies:
  flutter:
    sdk: flutter
  go_router: ^13.0.0

Now, let's set up a basic GoRouter configuration. Here's a simple example:


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

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

class MyApp extends StatelessWidget {
  final GoRouter _router = GoRouter(
    routes: [
      GoRoute(
        path: '/',
        builder: (context, state) => const HomeScreen(),
      ),
      GoRoute(
        path: '/details/:id',
        builder: (context, state) {
          final id = state.pathParameters['id']!;
          return DetailsScreen(id: id);
        },
      ),
    ],
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: _router,
    );
  }
}

That's it! With just a few lines of code, you have a router that handles URLs, path parameters, and navigation. Notice how we use MaterialApp.router instead of MaterialApp—this tells Flutter to use the router for navigation.

Navigating Between Screens

Navigation with GoRouter is straightforward. Instead of Navigator.push(), you use context.go() or context.push():


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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Navigate to details screen
            context.go('/details/123');
          },
          child: const Text('View Details'),
        ),
      ),
    );
  }
}

The difference between go() and push() is important:

  • context.go(): Replaces the current route (like web navigation)
  • context.push(): Pushes a new route onto the stack (like mobile navigation)

For most cases, go() is preferred because it works consistently across platforms and handles URLs properly.

go() vs push() Navigation Flow

context.go() - Replaces Route Home Details Home replaced context.push() - Adds to Stack Home Details Home still in stack Stack

Working with Parameters

GoRouter makes it easy to pass parameters through URLs. You've already seen path parameters (like :id), but you can also use query parameters:


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

// Navigate with query parameters
context.go('/search?q=flutter');

You can also pass extra data that doesn't appear in the URL:


context.push('/details/123', extra: {'user': currentUser});

// In your route builder:
GoRoute(
  path: '/details/:id',
  builder: (context, state) {
    final id = state.pathParameters['id']!;
    final extra = state.extra as Map?;
    return DetailsScreen(id: id, user: extra?['user']);
  },
),

Nested Routes and Shell Routes

One of GoRouter's powerful features is nested routing. This is perfect for apps with persistent navigation bars or bottom navigation:


final GoRouter router = GoRouter(
  routes: [
    ShellRoute(
      builder: (context, state, child) {
        return ScaffoldWithNavBar(child: child);
      },
      routes: [
        GoRoute(
          path: '/',
          builder: (context, state) => const HomeScreen(),
        ),
        GoRoute(
          path: '/profile',
          builder: (context, state) => const ProfileScreen(),
        ),
        GoRoute(
          path: '/settings',
          builder: (context, state) => const SettingsScreen(),
        ),
      ],
    ),
  ],
);

The ShellRoute wraps multiple routes with a persistent shell (like a bottom navigation bar). The child parameter contains the current route's content, which gets inserted into your shell layout.

Nested Routes with ShellRoute

ShellRoute Persistent Navigation Bar (Bottom Nav / Tab Bar) Home Route / Profile Route /profile Settings Route /settings Child Widget Current route content Shell persists, child changes based on route

Error Handling and Redirects

GoRouter provides excellent support for handling errors and redirects. You can redirect users based on authentication state or other conditions:


final GoRouter router = GoRouter(
  redirect: (context, state) {
    final isLoggedIn = AuthService.isAuthenticated();
    final isGoingToLogin = state.matchedLocation == '/login';

    if (!isLoggedIn && !isGoingToLogin) {
      return '/login';
    }
    if (isLoggedIn && isGoingToLogin) {
      return '/';
    }
    return null; // No redirect needed
  },
  routes: [
    // ... your routes
  ],
);

You can also handle errors gracefully:


final GoRouter router = GoRouter(
  errorBuilder: (context, state) => ErrorScreen(
    error: state.error,
  ),
  routes: [
    // ... your routes
  ],
);

Advanced Features

GoRouter supports many advanced features that make complex navigation scenarios manageable:

Named Routes

You can give routes names for easier navigation:


GoRoute(
  path: '/details/:id',
  name: 'details',
  builder: (context, state) => DetailsScreen(
    id: state.pathParameters['id']!,
  ),
),

// Navigate using the name
context.goNamed('details', pathParameters: {'id': '123'});

Route Observers

You can observe navigation changes for analytics or logging:


final GoRouter router = GoRouter(
  observers: [
    MyRouteObserver(),
  ],
  routes: [
    // ... your routes
  ],
);

class MyRouteObserver extends NavigatorObserver {
  @override
  void didPush(Route route, Route? previousRoute) {
    super.didPush(route, previousRoute);
    // Log navigation event
    Analytics.logScreenView(route.settings.name);
  }
}

Refresh Listenable

GoRouter can automatically refresh routes when a listenable changes:


final authNotifier = ValueNotifier(false);

final GoRouter router = GoRouter(
  refreshListenable: authNotifier,
  redirect: (context, state) {
    if (!authNotifier.value && state.matchedLocation != '/login') {
      return '/login';
    }
    return null;
  },
  routes: [
    // ... your routes
  ],
);

When authNotifier changes, GoRouter will re-evaluate redirects and update navigation accordingly.

Best Practices

Here are some tips to get the most out of GoRouter:

  • Use go() for most navigation: It's more predictable and works better with URLs
  • Keep route definitions organized: Group related routes together and use comments
  • Handle errors gracefully: Always provide an errorBuilder for better UX
  • Use redirects for authentication: It's cleaner than checking auth in every screen
  • Test deep links: Make sure your routes work when users open your app via URLs

Putting It All Together

Let's see a complete example that demonstrates many of these concepts:


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

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

class MyApp extends StatelessWidget {
  final GoRouter _router = GoRouter(
    initialLocation: '/',
    redirect: (context, state) {
      // Add your authentication logic here
      return null;
    },
    routes: [
      GoRoute(
        path: '/',
        name: 'home',
        builder: (context, state) => const HomeScreen(),
      ),
      GoRoute(
        path: '/products',
        name: 'products',
        builder: (context, state) => const ProductsScreen(),
        routes: [
          GoRoute(
            path: ':id',
            name: 'product-details',
            builder: (context, state) {
              final id = state.pathParameters['id']!;
              return ProductDetailsScreen(productId: id);
            },
          ),
        ],
      ),
      GoRoute(
        path: '/login',
        name: 'login',
        builder: (context, state) => const LoginScreen(),
      ),
    ],
    errorBuilder: (context, state) => ErrorScreen(error: state.error),
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: _router,
      title: 'My Flutter App',
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => context.goNamed('products'),
              child: const Text('View Products'),
            ),
            ElevatedButton(
              onPressed: () => context.go('/products/123'),
              child: const Text('View Product 123'),
            ),
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: ListView(
        children: [
          ListTile(
            title: const Text('Product 1'),
            onTap: () => context.goNamed(
              'product-details',
              pathParameters: {'id': '1'},
            ),
          ),
          ListTile(
            title: const Text('Product 2'),
            onTap: () => context.goNamed(
              'product-details',
              pathParameters: {'id': '2'},
            ),
          ),
        ],
      ),
    );
  }
}

class ProductDetailsScreen extends StatelessWidget {
  final String productId;

  const ProductDetailsScreen({
    super.key,
    required this.productId,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Product $productId')),
      body: Center(
        child: Text('Details for product $productId'),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: const Center(child: Text('Login Screen')),
    );
  }
}

class ErrorScreen extends StatelessWidget {
  final Exception? error;

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Error')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Something went wrong!'),
            if (error != null) Text(error.toString()),
            ElevatedButton(
              onPressed: () => context.go('/'),
              child: const Text('Go Home'),
            ),
          ],
        ),
      ),
    );
  }
}

Conclusion

GoRouter brings the power of Flutter Navigation 2.0 to your apps with a clean, intuitive API. Whether you're building a simple app or a complex one with deep linking, authentication, and nested navigation, GoRouter has you covered.

The declarative nature of GoRouter makes your navigation code easier to understand and maintain. Once you get comfortable with it, you'll find that navigation becomes one less thing to worry about, letting you focus on building great features for your users.

Start by converting a simple screen or two to use GoRouter, and gradually migrate the rest of your app. You'll quickly see the benefits, especially when working with web or handling deep links. Happy routing!