URL Strategy for Web Apps Using go_router in Flutter

URL Strategy for Web Apps Using go_router in Flutter

When developing web applications in Flutter, the URL strategy significantly impacts user experience. In particular, navigation is crucial in a web environment, where users may directly access pages via URLs or use the back button. In this article, we will introduce go_router, a powerful routing library in Flutter, and explore effective routing strategies using it.

Introduction to go_router

go_router is the officially recommended routing solution by the Flutter team. It addresses the complexities of the existing Navigator 2.0 while providing declarative and powerful routing capabilities.

Key Features

  • Declarative route definitions
  • Support for nested navigation
  • Support for URL parameters and query parameters
  • Deep link support
  • Redirect rules configuration
  • Error handling
  • Web platform optimization

Installation

Add the following dependency to your pubspec.yaml file:

dependencies:
  go_router: ^14.3.0 # Latest version as of October 2024.

1. Removing Hash Tags

The default URL strategy for Flutter web applications is hash-based (e.g., example.com/#/home). This is not good for SEO and degrades user experience. You can improve this by using usePathUrlStrategy().

import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  usePathUrlStrategy(); // 해시태그 제거
  runApp(MyApp());
}

2. Basic Configuration of go_router

2.1 Basic Router Setup

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

2.2 Integration with Riverpod

Integrating Riverpod with go_router allows for effective dynamic routing based on the app’s state. This makes routing based on authentication status or user permissions much easier.

Basic Setup

First, define the necessary providers:

// Provider for managing authentication status
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
  return AuthNotifier();
});

// router provider
final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authStateProvider);

  return GoRouter(
    navigatorKey: rootNavKey,
    refreshListenable: authState as Listenable,
    initialLocation: '/',
    routes: [
      // Route configuration
    ],
    redirect: (context, state) {
      // Redirect logic
      return null;
    },
  );
});

Implementation of Authentication State Class

class AuthState extends ChangeNotifier {
  AuthStatus _status = AuthStatus.initial;
  User? _user;

  AuthState() {
    // Initial authentication status check
    checkAuthStatus();
  }

  AuthStatus get status => _status;
  User? get user => _user;

  void setAuthenticated(User user) {
    _user = user;
    _status = AuthStatus.authenticated;
    notifyListeners();
  }

  void setUnauthenticated() {
    _user = null;
    _status = AuthStatus.unauthenticated;
    notifyListeners();
  }
}

Route Setup and Redirect logic

final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authStateProvider);

  return GoRouter(
    navigatorKey: rootNavKey,
    refreshListenable: authState as Listenable,
    initialLocation: '/',
    routes: [
      GoRoute(
        path: '/',
        builder: (context, state) => const HomeScreen(),
      ),
      GoRoute(
        path: '/login',
        builder: (context, state) => const LoginScreen(),
      ),
      GoRoute(
        path: '/dashboard',
        builder: (context, state) => const DashboardScreen(),
      ),
    ],
    redirect: (context, state) {
      final authStatus = authState.status;
      final isLoggingIn = state.matchedLocation == '/login';

      // Do not redirect If in the initial state
      if (authStatus == AuthStatus.initial) return null;

      // If an unauthenticated user tried to access a protected route
      if (authStatus == AuthStatus.unauthenticated) {
        return isLoggingIn ? null : '/login';
      }

      // If an authenticated user tries to access the login page
      if (authStatus == AuthStatus.authenticated && isLoggingIn) {
        return '/dashboard';
      }

      return null;
    },
  );
});

Example of Actual Usage

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(routerProvider);

    return MaterialApp.router(
      routerConfig: router,
      title: 'My App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
    );
  }
}

// Example of login handling
class LoginScreen extends ConsumerWidget {
  const LoginScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            // Perform login logic
            final user = await performLogin();

            // Update authentication status
            ref.read(authStateProvider.notifier).setAuthenticated(user);

            // go_router automatically handles redirection
          },
          child: const Text('Login'),
        ),
      ),
    );
  }
}

Integrating with Riverpod provides the following benefits:

  1. State-based Routing: Automatically redirect to the appropriate page based on changes in the app’s state.
  2. Code Structuring: Allows for separation of routing logic into provides for better management.
  3. Ease of Testing: Dependency injection through providers makes testing easier.
  4. Maintainability: Authentication-related logic is managed in one place, making maintenance easier.

2.3 Utilizing URL Parameters

GoRoute(
  path: '/user/:userId/posts/:postId',
  builder: (context, state) {
    final userId = state.pathParameters['userId']!;
    final postId = state.pathParameters['postId']!;
    return PostDetailScreen(userId: userId, postId: postId);
  },
),

2.4 Handling Query Parameters

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

3. Advanced Routing Features

3.1 Nested Navigation

GoRoute(
  path: '/dashboard',
  builder: (context, state) => const DashboardScreen(),
  routes: [
    GoRoute(
      path: 'profile',
      builder: (context, state) => const ProfileScreen(),
    ),
    GoRoute(
      path: 'settings',
      builder: (context, state) => const SettingsScreen(),
    ),
  ],
),

3.2 Redirect Configuration

redirect: (context, state) {
  final authState = ref.read(authStateProvider);
  final isLoginPage = state.matchedLocation == '/login';

  // Protected routes that require authentication
  final protectedPaths = ['/dashboard', '/profile', '/settings'];

  // Check if the current path is a protected route
  final isProtectedRoute = protectedPaths.any(
    (path) => state.matchedLocation.startsWith(path),
  );

  if (authState == AuthState.unauthenticated && isProtectedRoute) {
    // Redirect to the login page while saving the originally intend
    return '/login?redirect=${state.matchedLocation}';
  }

  if (authState == AuthState.authenticated && isLoginPage) {
    // If an authenticated user tries to access the login page, redirect to the dashboard
    return '/dashboard';
  }

  return null;
},

3.3 Error Handling

errorBuilder: (context, state) {
  return MaterialPage(
    key: state.pageKey,
    child: Scaffold(
      appBar: AppBar(
        title: const Text('An error has occurred'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(
              Icons.error_outline,
              color: Colors.red,
              size: 60,
            ),
            const SizedBox(height: 16),
            Text(
              'The requested page could not be found:\n${state.error}',
              textAlign: TextAlign.center,
              style: const TextStyle(fontSize: 16),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => context.go('/'),
              child: const Text('Go back to home'),
            ),
          ],
        ),
      ),
    ),
  );
},

4. Practical Usage Examples

4.1 Page Transition Animation

CustomTransitionPage<void>(
  key: state.pageKey,
  child: const DetailsScreen(),
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    return FadeTransition(
      opacity: animation,
      child: child,
    );
  },
),

4.2 Managing Page History

// Go back
context.pop();

// Navigate to a specific route (add to history)
context.push('/details/123');

// Navigate to a specific route (replace history)
context.replace('/new-route');

// Go to home and reset history
context.go('/');

5. Performance Optimization

5.1 Route Caching

final router = GoRouter(
  routes: routes,
  // Page caching configuration
  restorationScopeId: 'router',
  // Limit the number of pages to cache
  observers: [
    GoRouterObserver(
      navigatorObservers: () => [
        HeroController(),
      ],
    ),
  ],
);

6. Conclusion

go_router provides a powerful and flexible routing solution for Flutter web applications. It effectively handles various scenarios such as URL strategies, authentication processing, and nested navigation, and integrates seamlessly with Riverpod for clean state management.

In particular, in a web environment, SEO optimization, deep linking, and browser history management are crucial, and using go_router allows for effective implementation of these requirements.

For more detailed information and examples, you can check the official go_router documentation.