Type-Safe Routing in Flutter: A Custom Enum Approach vs go_router

Nov 20, 2025
8 min read
flutter routing go_router type-safety dart

A technical deep-dive comparing two routing strategies for Flutter applications: a lightweight custom enum-based solution versus the popular go_router package. Written from the perspective of building Mauzo, a production point-of-sale app with Firebase and Redux.

As CTO of Medikea, I've had the opportunity to explore different architectural approaches across various projects. One of my side projects, Mauzo (a Flutter-based point-of-sale app with Firebase backend and Redux state management), gave me the chance to experiment with routing patterns that emphasize type safety and developer experience.

In this article, I'll share our custom enum-based routing implementation in Mauzo and compare it with Flutter's popular declarative routing package, go_router. Both approaches aim to solve navigation challenges, but they take fundamentally different paths to get there.


The Problem with Traditional Flutter Navigation

Traditional Flutter navigation using Navigator.push() and Navigator.pushNamed() has several pain points:

  1. String-based routes are error-prone - typos only surface at runtime
  2. No compile-time safety - refactoring route names can break the app silently
  3. Inconsistent parameter passing - arguments passed via constructors or route settings
  4. Boilerplate code - repetitive MaterialPageRoute creation
  5. Poor discoverability - developers must remember route names

Both our custom approach and go_router address these issues, but with different philosophies.


Our Custom Enum-Based Routing: The Mauzo Approach

Design Philosophy

We built our routing system around three core principles:

  1. Type safety first - leverage Dart's enum system for compile-time guarantees
  2. Minimal abstraction - stay close to Flutter's Navigator API
  3. Zero dependencies - no external packages for routing

Implementation Overview

Here's our complete routing system:

enum AppRoutes {
  splash('/splash'),
  language('/language'),
  onboarding('/onboarding'),
  login('/login'),
  signup('/register'),
  forgotPassword('/forgot-password'),
  shopSetup('/shop-setup'),
  home('/home'),
  shopForm('/shops/form'),
  userForm('/users/form'),
  currency('/settings/currency'),
  subscription('/subscription'),
  exports('/exports'),
  stockCategories('/admin/stock-categories'),
  stockCategoryForm('/admin/stock-categories/form');

  final String path;
  const AppRoutes(this.path);

  // Centralized route map for MaterialApp
  static Map<String, WidgetBuilder> get routes => {
    AppRoutes.splash.path: (context) => const SplashPage(),
    AppRoutes.language.path: (context) => const LanguageSwitcherPage(),
    AppRoutes.onboarding.path: (context) => const OnboardingPage(),
    AppRoutes.login.path: (context) => const LoginPage(),
    AppRoutes.signup.path: (context) => const SignupPage(),
    AppRoutes.forgotPassword.path: (context) => const ForgotPasswordPage(),
    AppRoutes.shopSetup.path: (context) => const ShopSetupPage(),
    AppRoutes.home.path: (context) => const MainShell(),
    AppRoutes.shopForm.path: (context) => const ShopFormPage(),
    AppRoutes.userForm.path: (context) => const UserFormPage(),
    AppRoutes.currency.path: (context) => const CurrencyPickerPage(),
    AppRoutes.subscription.path: (context) => const SubscriptionPage(),
    AppRoutes.exports.path: (context) => const ExportsPage(),
    AppRoutes.stockCategories.path: (context) => const AdminStockCategoriesPage(),
    AppRoutes.stockCategoryForm.path: (context) => const StockCategoryFormPage(),
  };

  // Extension methods for common navigation patterns
  void push(BuildContext context) {
    Navigator.of(context).pushNamed(path);
  }

  void pushReplacement(BuildContext context) {
    Navigator.of(context).pushReplacementNamed(path);
  }

  void pushAndRemoveUntil(BuildContext context) {
    Navigator.of(context).pushNamedAndRemoveUntil(path, (route) => false);
  }

  // Static methods for routes requiring parameters
  static void pushUserDetails(BuildContext context, AdminUserModel user) {
    Navigator.of(context).push(
      MaterialPageRoute(builder: (context) => AdminUserDetailsPage(user: user)),
    );
  }

  static void pushStockCategoryForm(
    BuildContext context, {
    StockCategoryModel? category,
  }) {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => StockCategoryFormPage(category: category),
      ),
    );
  }
}

Usage Examples

Simple navigation:

// Navigate to home
AppRoutes.home.push(context);

// Replace current route with login
AppRoutes.login.pushReplacement(context);

// Navigate to subscription and clear stack
AppRoutes.subscription.pushAndRemoveUntil(context);

Parameterized routes:

// Navigate with typed parameters
AppRoutes.pushUserDetails(context, userModel);

AppRoutes.pushStockCategoryForm(
  context,
  category: existingCategory,
);

App initialization:

MaterialApp(
  initialRoute: AppRoutes.splash.path,
  routes: AppRoutes.routes,
  // ... other config
)

Real-World Usage Statistics

Across the Mauzo codebase, we have:

  • 16 named routes defined in the enum
  • 50+ navigation calls using AppRoutes
  • 2 parameterized static methods for complex routes
  • Zero runtime route errors since implementation

go_router: Flutter's Declarative Routing Solution

Overview

go_router is an official Flutter package (published by flutter.dev) that provides a declarative, URL-based approach to navigation. It's built on top of Flutter's Router API (Navigation 2.0) and offers advanced features for modern app development.

Key Features

  1. URL-based navigation - define routes with path templates (/user/:id)
  2. Deep linking support - handle web URLs and app links seamlessly
  3. Redirection logic - route guards for authentication flows
  4. Type-safe routes - code generation support via go_router_builder
  5. ShellRoute support - persistent UI elements (bottom nav, app bar)
  6. Web optimization - proper browser history and URL management

Typical go_router Implementation

final GoRouter _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
      routes: [
        GoRoute(
          path: 'details/:id',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            return DetailsPage(id: id);
          },
        ),
      ],
    ),
    ShellRoute(
      builder: (context, state, child) {
        return ScaffoldWithNavBar(child: child);
      },
      routes: [
        GoRoute(
          path: '/home',
          builder: (context, state) => const HomeScreen(),
        ),
        GoRoute(
          path: '/settings',
          builder: (context, state) => const SettingsScreen(),
        ),
      ],
    ),
  ],
  redirect: (context, state) {
    final isLoggedIn = /* check auth state */;
    if (!isLoggedIn && state.location != '/login') {
      return '/login';
    }
    return null;
  },
);

// Usage
context.go('/details/123');
context.push('/settings');

Head-to-Head Comparison

Aspect Custom Enum Approach (Mauzo) go_router
Type Safety βœ… Compile-time via enums βœ… Via code generation or manual typing
Learning Curve ⚑ Minimal - extends Navigator API πŸ“š Moderate - new concepts (ShellRoute, redirect, etc.)
Deep Linking ❌ Manual implementation required βœ… Built-in support
URL Path Parameters ❌ Not supported (static methods only) βœ… Native support (/user/:id)
Code Size ⚑ ~90 lines (zero dependencies) πŸ“¦ External package (~500KB)
Redirection/Guards πŸ”§ Manual Redux-based logic βœ… Built-in redirect callbacks
Web Browser History ❌ Basic support only βœ… Full integration with browser
Nested Navigation πŸ”§ Manual with IndexedStack βœ… ShellRoute abstraction
Compile-time Safety βœ… Enum guarantees βœ… With go_router_builder
IDE Autocomplete βœ… Excellent (enum members) βœ… Good (with code gen)
Testability βœ… Simple unit tests βœ… Testing utilities provided
Migration Effort ⚑ Low (familiar Navigator API) πŸ“š High (new mental model)
Maintenance ⚠️ Manual route map updates βœ… Declarative config
Package Stability βœ… No external dependencies ⚠️ 17 major versions since release

When to Choose Each Approach

Choose Custom Enum Routing (Mauzo-style) if you:

  • βœ… Want zero external dependencies for routing
  • βœ… Prefer staying close to Flutter's Navigator API
  • βœ… Don't need deep linking or complex URL parsing
  • βœ… Value simplicity over advanced features
  • βœ… Have a mobile-first app with simple navigation flows
  • βœ… Want absolute control over navigation behavior
  • βœ… Prefer Redux or similar state management for app flow

Choose go_router if you:

  • βœ… Need deep linking and URL-based navigation
  • βœ… Are building a web application or multi-platform app
  • βœ… Want built-in redirection/guards for auth flows
  • βœ… Need nested navigation with persistent shells
  • βœ… Prefer declarative routing configuration
  • βœ… Want browser history integration
  • βœ… Can tolerate breaking changes across versions

Hybrid Approach: Best of Both Worlds?

In some cases, you might combine both approaches:

  1. Use go_router for structure - handle deep links, web URLs, nested navigation
  2. Use typed enums for route names - maintain compile-time safety with go_router's named routes
// Define routes as enum
enum AppRoute {
  home,
  profile,
  settings,
}

// Use with go_router
final router = GoRouter(
  routes: [
    GoRoute(
      name: AppRoute.home.name,
      path: '/',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      name: AppRoute.profile.name,
      path: '/profile/:id',
      builder: (context, state) => ProfilePage(
        id: state.pathParameters['id']!,
      ),
    ),
  ],
);

// Navigate with type safety
context.goNamed(AppRoute.profile.name, pathParameters: {'id': '123'});

Lessons Learned from Mauzo

After implementing our custom routing system in production, here are the key takeaways:

What Worked Well

  1. Developer velocity - new team members grasped the system in minutes
  2. Refactoring confidence - renaming routes caused compile errors, not runtime crashes
  3. Zero bloat - no package dependencies for a core app concern
  4. Perfect IDE support - autocomplete showed all routes instantly

What We Missed

  1. Deep linking - had to manually parse URLs for app links
  2. Web URL management - browser back button required custom handling
  3. Route guards - implemented auth checks in Redux thunks instead of routing layer
  4. Nested navigation - used IndexedStack in MainShell rather than declarative routing

Would We Choose Differently?

For Mauzo's use case (mobile-first POS with Firebase + Redux), our custom approach was the right choice. The app doesn't need deep linking or complex URL patterns, and the simplicity has been a net positive.

However, if we were building a web-first application or needed public URL sharing, go_router would be the better fit despite its learning curve.


Performance Considerations

Custom Enum Approach

  • Initialization: Instant (no package overhead)
  • Navigation calls: Direct Navigator API calls (zero abstraction cost)
  • Memory footprint: Minimal (single enum + route map)
  • Bundle size impact: ~0KB (pure Dart code)

go_router

  • Initialization: Router configuration parsing at startup
  • Navigation calls: Router matching + path parsing overhead
  • Memory footprint: Route tree maintained in memory
  • Bundle size impact: ~500KB (package + dependencies)

For most apps, these differences are negligible. Choose based on features, not performance.


Migration Path

From Custom Enum to go_router

  1. Install go_router:

    dependencies:
      go_router: ^17.0.0
    
  2. Convert route map to GoRouter config:

    final router = GoRouter(
      routes: [
        GoRoute(
          name: AppRoutes.home.name,
          path: AppRoutes.home.path,
          builder: (context, state) => const MainShell(),
        ),
        // ... convert remaining routes
      ],
    );
    
  3. Update MaterialApp:

    MaterialApp.router(
      routerConfig: router,
    );
    
  4. Replace navigation calls:

    // Before
    AppRoutes.home.push(context);
    
    // After
    context.goNamed(AppRoutes.home.name);
    

Conclusion

Both routing approaches have their place in the Flutter ecosystem. Our custom enum-based routing in Mauzo prioritizes simplicity, type safety, and zero dependenciesβ€”perfect for mobile apps with straightforward navigation needs. Meanwhile, go_router excels at web-first apps, deep linking, and declarative routing patterns.

The best choice depends on your project's requirements:

  • Simple mobile app? β†’ Custom enum approach
  • Web platform or deep linking? β†’ go_router
  • Need both? β†’ Hybrid approach

The key insight: Don't choose routing based on popularityβ€”choose based on your app's navigation complexity and platform targets.


Additional Resources


Questions or comments? Reach out to discuss routing patterns and Flutter architecture at Medikea!