← Back to Articles

Flutter Navigation: Mastering GoRouter for Declarative Routing

Flutter Navigation: Mastering GoRouter for Declarative Routing

Flutter Navigation: Mastering GoRouter for Declarative Routing

If you've been building Flutter apps for a while, you've probably encountered the challenges of navigation. The traditional Navigator.push() approach works fine for simple apps, but as your app grows, managing navigation state, deep links, and complex routing scenarios becomes increasingly difficult.

Enter GoRouter—Flutter's recommended solution for declarative routing. In this article, we'll explore what makes GoRouter special, how it solves common navigation problems, and how to implement it in your Flutter apps.

Why GoRouter?

Before diving into GoRouter, let's understand why it exists. Traditional navigation in Flutter relies on an imperative approach where you explicitly tell the framework what to do:


Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => DetailsPage(id: 123)),
);

This works, but it has limitations:

  • Deep linking requires manual URL parsing
  • Browser back/forward buttons don't work naturally
  • Navigation state is scattered throughout your app
  • Testing navigation flows is complex
  • Handling authentication redirects is cumbersome

GoRouter solves these problems by introducing a declarative routing system where you define your routes upfront, and the router handles navigation based on URLs and application state.

Understanding Declarative Routing

In declarative routing, you describe what your navigation structure should be, not how to navigate. Think of it like defining a map of your app—GoRouter uses this map to figure out which screen to show based on the current URL and app state.

Declarative vs Imperative Navigation Imperative push() / pop() Declarative Route Definition GoRouter Automatic

Getting Started with GoRouter

First, add GoRouter to your pubspec.yaml:


dependencies:
  flutter:
    sdk: flutter
  go_router: ^14.0.0

Then, define your router configuration. Here's a basic example:


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

final GoRouter router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/details/:id',
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return DetailsPage(id: int.parse(id));
      },
    ),
  ],
);

In your main.dart, use MaterialApp.router instead of MaterialApp:


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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
      title: 'GoRouter Example',
    );
  }
}

Navigation Patterns

Once your router is set up, navigation becomes much simpler. Instead of Navigator.push(), you use context.go() or context.push():


// Navigate to a route
context.go('/details/123');

// Push a new route (adds to stack)
context.push('/details/123');

// Go back
context.pop();

The key difference: go() replaces the current route, while push() adds to the navigation stack. Use go() when you want to navigate to a completely different section of your app, and push() when you're drilling down into details.

Navigation Stack Comparison Home List Details context.push() Adds to stack Home Details context.go() Replaces route

Advanced Features

Nested Routing

GoRouter excels at nested routing, which is perfect for apps with bottom navigation bars or tab bars. You can define parent routes with child routes:


GoRoute(
  path: '/home',
  builder: (context, state) => const HomeShell(),
  routes: [
    GoRoute(
      path: 'feed',
      builder: (context, state) => const FeedPage(),
    ),
    GoRoute(
      path: 'profile',
      builder: (context, state) => const ProfilePage(),
    ),
  ],
),

This creates routes like /home/feed and /home/profile, and the HomeShell widget can display a persistent navigation bar while child routes change.

Query Parameters

GoRouter makes it easy to handle query parameters:


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

Navigate with: context.go('/search?q=flutter')

Redirects and Guards

One of GoRouter's most powerful features is 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 '/home';
    }
    
    return null; // No redirect needed
  },
  routes: [
    // ... your routes
  ],
);

The redirect function runs before every navigation. Return a path to redirect, or null to proceed normally.

Error Handling

GoRouter provides built-in error handling for unknown routes:


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

Deep Linking Made Easy

Deep linking is where GoRouter really shines. Since your routes are URL-based, deep links work automatically. If a user clicks a link like myapp://details/456, GoRouter will navigate directly to that screen.

For web apps, this means users can bookmark specific pages, share URLs, and use browser back/forward buttons naturally. For mobile apps, you can handle universal links and app links with minimal code.

Deep Linking Flow External Link myapp://details/123 GoRouter Parses URL Route Match /details/:id DetailsPage id: 123

Best Practices

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

  • Use named routes consistently: Define all your routes in one place for easier maintenance.
  • Leverage path parameters: Use :id syntax for dynamic segments instead of query parameters when the ID is part of the resource path.
  • Keep redirects simple: Complex redirect logic can be hard to debug. Consider extracting it to a separate function.
  • Test your routes: Write tests that verify navigation flows, especially for authentication redirects.
  • Use go() for top-level navigation: Reserve push() for detail screens and modals.

Common Pitfalls

As you adopt GoRouter, watch out for these common mistakes:

  • Forgetting to use MaterialApp.router: Regular MaterialApp won't work with GoRouter.
  • Mixing Navigator and GoRouter: Stick to one navigation system. Using both can cause conflicts.
  • Not handling authentication state changes: If auth state changes, you may need to call router.refresh() to re-evaluate redirects.
  • Over-nesting routes: Deep nesting can make URLs hard to read and maintain.

Real-World Example

Let's put it all together with a complete example:


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

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

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

  final _router = GoRouter(
    initialLocation: '/home',
    redirect: (context, state) {
      // Add your auth logic here
      return null;
    },
    routes: [
      GoRoute(
        path: '/login',
        builder: (context, state) => const LoginPage(),
      ),
      GoRoute(
        path: '/home',
        builder: (context, state) => const HomePage(),
        routes: [
          GoRoute(
            path: 'products',
            builder: (context, state) => const ProductsPage(),
          ),
          GoRoute(
            path: 'products/:id',
            builder: (context, state) {
              final id = state.pathParameters['id']!;
              return ProductDetailsPage(id: id);
            },
          ),
        ],
      ),
    ],
  );

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

class HomePage extends StatelessWidget {
  const HomePage({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.go('/home/products'),
              child: const Text('View Products'),
            ),
          ],
        ),
      ),
    );
  }
}

class ProductsPage extends StatelessWidget {
  const ProductsPage({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.push('/home/products/1'),
          ),
          ListTile(
            title: const Text('Product 2'),
            onTap: () => context.push('/home/products/2'),
          ),
        ],
      ),
    );
  }
}

class ProductDetailsPage extends StatelessWidget {
  final String id;
  
  const ProductDetailsPage({super.key, required this.id});

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

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

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

Conclusion

GoRouter represents a significant step forward in Flutter navigation. By adopting a declarative approach, it solves many of the pain points developers face with traditional navigation, especially around deep linking, authentication flows, and complex routing scenarios.

While there's a learning curve when switching from imperative navigation, the benefits are substantial. Your code becomes more maintainable, deep linking works out of the box, and testing navigation becomes much easier.

Start with simple routes and gradually add more advanced features like redirects and nested routing. Before you know it, you'll wonder how you ever managed without GoRouter.

Happy routing!