Type-Safe Routing in Flutter: A Custom Enum Approach vs go_router
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:
- String-based routes are error-prone - typos only surface at runtime
- No compile-time safety - refactoring route names can break the app silently
- Inconsistent parameter passing - arguments passed via constructors or route settings
- Boilerplate code - repetitive
MaterialPageRoutecreation - 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:
- Type safety first - leverage Dart's enum system for compile-time guarantees
- Minimal abstraction - stay close to Flutter's Navigator API
- 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
- URL-based navigation - define routes with path templates (
/user/:id) - Deep linking support - handle web URLs and app links seamlessly
- Redirection logic - route guards for authentication flows
- Type-safe routes - code generation support via
go_router_builder - ShellRoute support - persistent UI elements (bottom nav, app bar)
- 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:
- Use go_router for structure - handle deep links, web URLs, nested navigation
- 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
- Developer velocity - new team members grasped the system in minutes
- Refactoring confidence - renaming routes caused compile errors, not runtime crashes
- Zero bloat - no package dependencies for a core app concern
- Perfect IDE support - autocomplete showed all routes instantly
What We Missed
- Deep linking - had to manually parse URLs for app links
- Web URL management - browser back button required custom handling
- Route guards - implemented auth checks in Redux thunks instead of routing layer
- Nested navigation - used IndexedStack in
MainShellrather 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
Install go_router:
dependencies: go_router: ^17.0.0Convert 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 ], );Update MaterialApp:
MaterialApp.router( routerConfig: router, );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
- Author's Blog: Type-Safe Routes in Angular
- Medikea: https://medikea.co.tz
- go_router Package: https://pub.dev/packages/go_router
- Flutter Navigation 2.0: Official Flutter Docs
- Type-safe Routes with go_router: go_router_builder
Questions or comments? Reach out to discuss routing patterns and Flutter architecture at Medikea!