Routing Best Practices
The recommended convention for organizing routes and navigation in a
go_router_modular app. Following it keeps route literals in one place, makes
refactors safe, and keeps your modules fast to boot. This page is opinionated
guidance — for the underlying API, see Routes & Modules
and Navigation.
The three rules
- Navigate only by name. Never call
context.go('/raw-path'). Every navigation goes through a feature’s*Routeclass, which delegates togoNamed/pushNamed. Raw path strings scatter route literals across the app and break silently when a path changes; a named route plus a single constants file means one place to edit. - Every
ChildRoutedeclares aname. Withoutname, named navigation can’t resolve the route. The name lives on the leafChildRoute. - Keep modules synchronous. Prefer synchronous
binds(Injector i)andimports(). Anasync/Future-returning module delays route registration and the first frame; use it only when genuinely unavoidable.
A route file per feature
Next to each feature’s <feature>_module.dart, keep a <feature>_route.dart
holding two classes with distinct, semantic jobs:
<Feature>RouteRelative— path/name constants, param keys, and the static path-param readers (e.g.getMyIdParam(state)). No navigation, no context. Keeping the reader here puts it next to the param key it reads.<Feature>Route— the navigation surface, built from aBuildContext(MyRoute.of(context)). It answers “how do I go there?” and is the only place that callsgoNamed/pushNamed.
// lib/src/modules/my/my_route.dart
import 'package:flutter/widgets.dart';
import 'package:go_router_modular/go_router_modular.dart';
/// Path/name constants, param keys, and the static param readers. No navigation here.
class MyRouteRelative {
MyRouteRelative._();
// params
static const String param$id = 'id';
/// Mount path of this module inside the parent module.
static const String myModule = '/my';
/// Relative path mounted as a ChildRoute (usually '/').
static const String my = '/';
static const String myNamed = 'my-feature';
/// Relative path with a path param.
static const String myDetail$id = '/detail/:${param$id}';
static const String myDetailNamed = 'detail';
/// Reads the `id` path param from the current route state.
static String getMyIdParam(GoRouterState state) =>
state.pathParameters[param$id] as String;
}
/// Navigation entry point. Built from a BuildContext.
class MyRoute {
MyRoute.of(this.context);
final BuildContext context;
void go() => context.goNamed(MyRouteRelative.myNamed);
void push() => context.pushNamed(MyRouteRelative.myNamed);
// Route with a parameter: arguments go through the method, never the call site.
void pushMyDetail({required String id}) => context.pushNamed(
MyRouteRelative.myDetailNamed,
pathParameters: {MyRouteRelative.param$id: id},
);
}Rule 1 — navigate only by name
Always navigate through the feature’s *Route class. Pass pathParameters and
extra through its methods, so call sites never assemble route strings or
parameter maps.
// ❌ Incorrect — raw path strings scattered across call sites.
context.go('/my');
context.push('/my/detail/42');
// ✅ Correct — named, via the navigation class.
MyRoute.of(context).go();
MyRoute.of(context).pushMyDetail(id: '42');A raw context.go('/my') breaks silently the day the path changes. Route
literals belong in *RouteRelative and nowhere else.
Rule 2 — name on ChildRoute, and module composition
In Module.routes, set each ChildRoute’s path from a relative constant and
its name from the matching *Named constant. Read path params via the
*RouteRelative static reader.
class MyModule extends Module {
@override
void binds(Injector i) {
i
// DataSources
..addSingleton<MyDataSource>((i) => MyDataSource(i.get<DioClient>()))
// Repositories
..addSingleton<MyRepository>((i) => MyRepository(i.get<MyDataSource>()))
// Controllers
..add<MyController>((i) => MyController(i.get<MyRepository>()));
}
@override
List<ModularRoute> get routes => [
ChildRoute(
MyRouteRelative.my,
name: MyRouteRelative.myNamed,
child: (context, _) => MyPage(controller: Modular.get<MyController>()),
),
ChildRoute(
MyRouteRelative.myDetail$id,
name: MyRouteRelative.myDetailNamed,
child: (context, state) => MyDetailPage(
controller: Modular.get<MyController>(),
id: MyRouteRelative.getMyIdParam(state),
),
),
];
}The *Module constant defines where this module is mounted in its parent via
ModuleRoute:
// In the parent module:
ModuleRoute(MyRouteRelative.myModule, module: MyModule());ModuleRoute also accepts a name if you need to target the module mount point
itself; prefer naming the leaf ChildRoute you actually navigate to.
Rule 3 — synchronous modules, binds via the .. cascade
Prefer synchronous binds/imports. They register immediately, so routes are
ready without delaying the first frame. Use a block body for binds and
register on the injector with the cascade operator (..) — one i followed by a
chain of ..addX, grouped by layer with comments. Always pass the explicit
type so the bind is indexed under that exact type and resolves via direct
lookup.
@override
void binds(Injector i) {
i
// Services
..addSingleton<AnalyticsService>((i) => AnalyticsService())
// DataSources
..addSingleton<MyRemoteDataSource>((i) => MyRemoteDataSource(i.get<DioClient>()))
// Repositories
..addSingleton<MyRepository>((i) => MyRepository(i.get<MyRemoteDataSource>()))
// Controllers
..add<MyController>((i) => MyController(i.get<MyRepository>()));
}
@override
List<Module> imports() => [SharedModule()];When a dependency needs an awaited resource, do not make the module async.
Instead, await it in main() before Modular.configure, then pass the ready
instance into AppModule through its constructor and register it synchronously.
// lib/main.dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Resolve async resources up front, before configuring.
final prefs = await SharedPreferences.getInstance();
await Modular.configure(
appModule: AppModule(prefs: prefs), // pass it in
initialRoute: '/',
);
runApp(const AppWidget());
}The package does support an async binds
(Future<void> binds(Injector i) async {...}), but prefer hoisting awaited
work into main() — async modules delay route registration and the first
frame. Reach for async binds only when an awaited resource genuinely cannot
be hoisted, and keep the awaited work minimal.
Naming conventions
| Element | Convention | Example |
|---|---|---|
| Constants + readers class | suffix *RouteRelative | MyRouteRelative |
| Navigation class | suffix *Route (built via .of(context)) | MyRoute, ProfileRoute |
| Route name string | kebab-case | my-feature, detail |
| Param key constant | prefix param$ | param$id |
| Relative path constant | the feature/leaf name | my |
| Module mount constant | suffix *Module | myModule |
| Name constant | suffix *Named | myNamed, myDetailNamed |
| Param-bearing path | suffix *$<param> | myDetail$id |
Checklist
- Each feature with routes has a
*_route.dartnext to its*_module.dart, with a*RouteRelativeconstants/readers class and a*Routenavigation class. - Every
ChildRoutehas bothpath(from a relative constant) andname(from a*Namedconstant). - No
context.go('/...')/context.push('/...')with raw path strings — navigation goes through*Route. - Path params are read via the
*RouteRelativestatic reader, not inlinestate.pathParameters[...]at the call site. binds/importsare synchronous; async resources are awaited inmain()and passed intoAppModulevia its constructor.- Binds and lookups are typed:
addSingleton<T>/add<T>on registration, andi.get<T>()/Modular.get<T>()on resolution.