A Flutter router built around a typed route tree and a DSL-style route definition API.
Location<ID, Self>is a semantic route node.Shell<ID>is a directly constructible structural route node that inserts a nested navigator.MultiShell<ID>is the parallel-shell variant for layouts with multiple sibling nested navigators.- Routes are defined in
build(...)with ordered builder calls:pathLiteral(...)pathParam(...)queryParam(...)
- The same
build(...)method also decides whether the location is:- legacy when no render is configured, so
buildRootPageshandles it - self-built via
builder.content = ..., with an optional page override frombuilder.page = ... - and returns the child
RouteNodes as a list
- legacy when no render is configured, so
@RouteNodes()generates typedrouteToX(...)helpers andXRouteTargetclasses, plus owner-boundchildXTarget(...)helpers, from one canonical route-tree file.
The generator works best when you keep one canonical route-tree file and let everything else import it.
- Create a dedicated route-tree file such as
example/lib/route_nodes.dart. - Put
part 'route_nodes.g.dart';in that file. - Annotate the canonical
buildRouteNodes(...)entrypoint with@RouteNodes(). - Return the root nodes from that function.
- Build the router with
buildRouteNodes: (rootRouterKey) => buildRouteNodes(rootRouterKey: rootRouterKey, ...).
Convention:
- treat
route_nodes.dartas the public route-layer barrel for the app - keep
buildRouteNodes(...)and the generatedpartthere - and
exportthe feature files that define route-node subclasses when app code should import those node types or generated child-target extensions through a single route API surface
See:
The route API is centered around lightweight route-node subclasses that forward a
typed build: callback.
class ExampleNode extends Location<MyRouteId, ExampleNode> {
ExampleNode({
super.id,
super.build,
});
}
final example = ExampleNode(
id: MyRouteId.example,
build: (builder, location) {
builder.pathLiteral('items');
final itemId = builder.stringPathParam();
final filter = builder.stringQueryParam(
'filter',
defaultValue: Default('all'),
);
builder.content = Content.builder((context, data) {
return Text(
'${data.param(itemId)}:${data.param(filter)}',
);
});
builder.children = [
DetailNode(id: MyRouteId.detail),
];
},
);Important details:
- Path order is defined by call order inside
build(...). - Query parameter names are explicit strings on
queryParam(...). - The builder also exposes typed shortcuts like
stringPathParam(),intQueryParam('page'),intQueryParam('page', defaultValue: Default(1)),uriPathParam(),uriQueryParam('next'),enumPathParam(MyEnum.values), andenumQueryParam('filter', MyEnum.values, defaultValue: Default(MyEnum.all)). - For nullable query params with a default
null, use the nullable shortcuts likenullableStringQueryParam('filter'),nullableBoolQueryParam('enabled'), ornullableDateTimeQueryParam('endDateTime'). Those nullable shortcuts always default tonulland do not accept a custom default value. - Path parameters are intentionally non-nullable. They represent matched URI
segments, so a missing value means the route does not match rather than
producing
null. Use query parameters for optional values. - Child routes are assigned with
builder.children = [...]. - Use
Location(...),Scope(...),Shell(...),ShellLocation(...),MultiShell(...), andMultiShellLocation(...)for callback-based route definitions, or subclassAbstractLocation,AbstractScope,AbstractShell,AbstractShellLocation,AbstractMultiShell, andAbstractMultiShellLocationto overridebuild(...)directly. - Page keys can be configured with
builder.pageKey = ..., usingPageKey.templatePath(),PageKey.path(), orPageKey.custom(...). builder.content = Content.widget(...)is the constant-widget variant.builder.content = Content.builder(...)is the data-aware variant.builder.content = const Content.none()creates a semantic non-rendering location that can still be terminal.builder.page = ...only overrides the default page wrapper around rendered content.- For rare cross-cutting cases, define reusable unbound params with
UnboundPathParam/UnboundQueryParam, bind them withbuilder.bindParam(...), read the boundParamwithdata.param(...), and read the reusable unbound definition withdata.paramOrNull(...). WorkingRouterDataexposes the full matched chain asdata.routeNodes. Usedata.leafwhen you specifically need the active semantic location.contentanddefaultContentmay depend oncontextanddata, but they should not switch semantic page role based on other external mutable state.- If
contentis left entirely unset, the location is treated as legacy and resolved throughbuildRootPages.
Reusable unbound params are mainly useful when outer code needs nullable access to a route param without owning the location that declares it:
final accountId = UnboundPathParam<AccountId>(const AccountIdCodec());
Location<RouteId, AccountsNode>(
build: (builder, location) {
builder.pathLiteral('accounts');
final boundAccountId = builder.bindParam(accountId);
builder.children = [
DashboardNode(
build: (builder, location) {
builder.content = Content.builder((context, data) {
return Text(data.param(boundAccountId).toString());
});
},
),
];
},
);
// Somewhere outer in the widget tree:
final activeAccountId = data.paramOrNull(accountId);If a route node itself should keep access to a bound param after build(...),
store the returned Param<T> on the node instance:
class AccountNode extends AbstractShell<RouteId> {
late final Param<AccountId> accountId;
@override
void build(ShellBuilder<RouteId> builder) {
accountId = builder.pathParam(const AccountIdCodec());
builder.children = [
DashboardNode(id: RouteId.dashboard, accountId: accountId),
];
}
}This is useful when matched node instances should expose their bound params for safe reads later.
The generator supports this assignment pattern when producing typed route helpers.
See:
Use a Scope when you want a shared route scope without rendering anything.
A scope:
- can define shared path and query parameters
- can hold child locations
- does not build a page
- does not create a nested navigator
Typical use case:
- multiple legal pages share the same
languageCodequery parameter - a subtree shares a path prefix but has no shared UI wrapper
Use a Shell when the subtree needs its own visible wrapper and nested
navigator boundary. A shell:
- can define shared path and query parameters
- can hold child locations
- does build a wrapper widget/page
- does create a nested navigator for its child subtree
- may define
defaultContent/defaultPagefor that implicit nested slot
If no later matched descendant is actually assigned to the shell's
routerKey, the shell does not contribute a page for that match and behaves
like a Scope instead. This lets you keep a shell in the tree for shared
path/query scope while routing descendants to an ancestor navigator on smaller
layouts. When defaultContent is configured, that default page becomes the
root page of the shell navigator and keeps the shell renderable even if the
matched descendants are all routed elsewhere.
You can also disable the shell navigator explicitly with
navigatorEnabled: false. In that mode the shell stays in the tree for
path/query structure, but descendants inherit the shell parent navigator
automatically. Explicit parentRouterKey: routerKey references are also
aliased back to that parent navigator, so responsive shells do not require
rewriting every child.
Typical use case:
- a sidebar or tab layout that stays visible while child routes change
- an account area like
/accounts/:id/...where children render inside a common scaffold
Use a ShellLocation when that nested navigator boundary belongs to exactly
one semantic location instead of a shell plus one child location. A shell
location:
- has an
idlike a normal location - defines its own path, query params, widget, and page
- also defines an outer shell wrapper/page on the parent navigator
- creates a nested navigator for its child subtree
- may define
defaultContent/defaultPagefor that implicit nested slot - can disable that nested navigator with
navigatorEnabled: false
Typical use case:
- a
/settingsroute that opens in a modal shell and then renders nested/settings/theme-modepages inside that modal - a flow root that needs both a semantic location id and an outer container
page without introducing an extra
Shell -> Locationnesting level
Use a MultiShell when one wrapper needs multiple sibling nested navigators,
such as a split view with independent left and right stacks. Use a
MultiShellLocation when that split shell is also a semantic location with an
id and an inner location page. Extra multi-shell slots may define default
content and page wrappers. If an enabled slot has neither routed content nor
default content, the router throws instead of silently leaving that pane
empty. A slot's default page stays in the same navigator and acts as that
slot's root page beneath deeper routed pages.
Use the callback-based types when defining a tree inline:
Scope(
build: (builder, scope) {
builder.children = [
PrivacyNode(id: RouteId.privacy, build: ...),
];
},
);
Shell(
build: (builder, shell, routerKey) {
builder.content = ShellContent.builder(
(context, data, child) => Scaffold(body: child),
);
builder.defaultContent = DefaultContent.widget(const Placeholder());
builder.children = [
DashboardNode(id: RouteId.dashboard, build: ...),
];
},
);
MultiShell(
build: (builder, shell) {
final listSlot = builder.slot(
defaultContent: DefaultContent.widget(const ChannelListScreen()),
);
final detailSlot = builder.slot();
builder.content = MultiShellContent.builder(
(context, data, slots) => Row(
children: [
Expanded(child: slots.child(listSlot)),
Expanded(child: slots.child(detailSlot)),
],
),
);
builder.children = [
SearchNode(parentRouterKey: listSlot.routerKey, build: ...),
DetailNode(
id: RouteId.detail,
parentRouterKey: detailSlot.routerKey,
build: ...,
),
];
},
);
ShellLocation<RouteId, SettingsNode>(
id: RouteId.settings,
build: (builder, location, routerKey) {
builder.shellPage = (key, child) =>
MaterialPage(key: key, child: child);
builder.content = Content.widget(const SettingsScreen());
builder.children = [
ThemeModeNode(id: RouteId.themeMode, build: ...),
];
},
);Use the abstract base classes when you want a reusable named subtree by
overriding build(...):
class LegalNode extends AbstractScope<RouteId> {
@override
void build(ScopeBuilder<RouteId> builder) {
builder.children = [
PrivacyNode(id: RouteId.privacy, build: ...),
TermsNode(id: RouteId.terms, build: ...),
];
}
}
class AccountNode extends AbstractShell<RouteId> {
@override
void build(ShellBuilder<RouteId> builder) {
builder.content = ShellContent.builder(
(context, data, child) => Scaffold(body: child),
);
builder.children = [
DashboardNode(id: RouteId.dashboard, build: ...),
];
}
}
class ChatSplitNode extends AbstractMultiShell<RouteId> {
@override
void build(MultiShellBuilder<RouteId> builder) {
final listSlot = builder.slot();
final detailSlot = builder.slot();
builder.content = MultiShellContent.builder(
(context, data, slots) => Row(
children: [
Expanded(child: slots.child(listSlot)),
Expanded(child: slots.child(detailSlot)),
],
),
);
builder.children = [
SearchNode(parentRouterKey: listSlot.routerKey, build: ...),
DetailNode(
id: RouteId.detail,
parentRouterKey: detailSlot.routerKey,
build: ...,
),
];
}
}
class SettingsNode extends AbstractShellLocation<RouteId, SettingsNode> {
SettingsNode({required super.id});
@override
void build(ShellLocationBuilder<RouteId> builder) {
builder.content = Content.widget(const SettingsScreen());
builder.children = [
ThemeModeNode(id: RouteId.themeMode, build: ...),
];
}
}By default, pages use PageKey.templatePath().
builder.pageKey = const PageKey.templatePath();This keys a page by its route template, not by hydrated path values. That
means /lesson/1 and /lesson/2 reuse the same page identity, while
/lesson/1/edit becomes a different page. This is usually the right default
when changing a path parameter should update the existing page instead of
replacing it.
In practice, that means:
- a detail screen can switch from item
1to item2without replacing the page - page-level state tied to that page key stays alive
- nested widgets can still react to the new path parameter and rebuild
- navigating to
/lesson/1/editstill creates a different page
If the hydrated path value should produce a different page identity, use
PageKey.path() instead:
builder.pageKey = const PageKey.path();This keys by the matched path, so /lesson/1 and /lesson/2 become different
pages. Use it when route parameter changes should reset page-level state or
animate like a page replacement.
In practice, that means:
- going from
/lesson/1to/lesson/2behaves like a new page - page-level state is reset because the page key changes
- page transitions can animate like a replacement instead of an in-place update
Example:
LessonLocation(
id: RouteId.lesson,
build: (builder, location) {
final lessonId = builder.stringPathParam();
builder.content = Content.builder((context, data) {
return LessonScreen(lessonId: data.param(lessonId));
});
builder.pageKey = const PageKey.templatePath();
},
);Use PageKey.templatePath() if changing lessonId should keep the same page.
Use PageKey.path() if changing lessonId should replace that page.
For everything else, use PageKey.custom(...).
Shell stays directly constructible:
Shell(
navigatorEnabled: screenSize != ScreenSize.small,
build: (builder, shell, routerKey) {
builder.content = ShellContent.builder((context, data, child) {
return Scaffold(body: child);
});
builder.children = [SomeLocation(id: MyRouteId.some)];
},
)Shells create their own nested navigator keys internally. When
navigatorEnabled is false, the builder still receives that stable shell key,
but routing ownership aliases it back to the shell parent navigator. That
means children can either inherit implicitly or keep using
parentRouterKey: routerKey without forcing a second responsive tree.
Nested shell routing is hosted by a stateful NestedRouting widget, so the
nested delegate keeps its own navigator key and stack across
WorkingRouter.refresh() as long as that shell widget is reused. That is what
makes dynamic route-tree refreshes practical here, because nested navigator
state can survive tree changes that still keep the same shell alive.
This is shown in the package example in
example/lib/route_nodes.dart.
ShellLocation is the shorthand for the common Shell + one child Location
shape:
ShellLocation<RouteId, SettingsNode>(
id: RouteId.settings,
navigatorEnabled: screenSize != ScreenSize.small,
build: (builder, location, routerKey) {
builder.pathLiteral('settings');
builder.shellContent = ShellContent.builder((context, data, child) {
return Dialog(child: child);
});
builder.defaultContent = DefaultContent.widget(const Placeholder());
builder.content = Content.widget(const SettingsScreen());
builder.page = (key, child) {
return MaterialPage(key: key, child: child);
};
builder.children = [
ThemeModeNode(id: RouteId.themeMode, build: ...),
];
},
)Use:
content = ...andpage = ...for the inner location page rendered inside the nested navigatordefaultContent = ...anddefaultPage = ...for the implicit nested slot root page, especially whencontent = const Content.none()shellContent = ...andshellPage = ...for the outer shell wrapper rendered on the parent navigatornavigatorEnabled: falsewhen the shell location should collapse down to a normal location on smaller layouts while keeping the same tree shape
MultiShellLocation is the parallel-shell variant for layouts with multiple
sibling slot navigators plus one built-in contentSlot for the location's own
page, such as a desktop split view with independent left and right stacks.
MultiShellLocation<RouteId, ChatLocation>(
id: RouteId.chat,
navigatorEnabled: screenSize != ScreenSize.small,
build: (builder, location, contentSlot) {
builder.pathLiteral('chat');
final listSlot = builder.slot(
defaultContent: DefaultContent.widget(const ChannelListScreen()),
);
builder.shellContent = MultiShellContent.builder((
context,
data,
slots,
) {
return ChatScreen(
leftChild: slots.child(listSlot),
child: slots.child(contentSlot),
);
});
builder.defaultContent = DefaultContent.widget(
const EmptyDetailPlaceholder(),
);
builder.content = const Content.none();
builder.children = [
ChannelListNode(
id: RouteId.channelList,
parentRouterKey: listSlot.routerKey,
build: ...,
),
ChannelDetailNode(
id: RouteId.channelDetail,
parentRouterKey: contentSlot.routerKey,
build: ...,
),
];
},
)Use:
- the
contentSlotbuild parameter for the location's own page navigator defaultContent = ...anddefaultPage = ...for the implicitcontentSlotroot pagebuilder.slot()for extra sibling navigatorsslot.routerKeyto target any slot from child locations viaparentRouterKeyslots.child(slot)insideshellContentto place each active slot navigatorslots.childOrNull(slot)when a disabled slot should simply be omitted from the layoutnavigatorEnabled: falseto collapse the whole multi-shell back onto the parent navigator on smaller layouts while keeping the same route tree
From @RouteNodes(), the generator emits:
routeToX(...)helpers onWorkingRouterSailorXRouteTarget(...)classes for typed imperative navigation and redirectschildXTarget(...)extension helpers on concrete location types for owner-bound child routing
For owner-bound child targets:
- if the same owner could reach multiple descendants that would generate the
same
childXTarget(...)helper, the generator suppresses that ambiguous ancestor helper and logs a warning instead of generating an unsafe API
That means you can navigate either with:
router.routeToAbc(id: 'test', b: 'bee', c: 'see');or:
router.routeTo(AbcRouteTarget(id: 'test', b: 'bee', c: 'see'));Redirects can use the same targets:
return RedirectTransition(AbcRouteTarget(id: 'test', b: 'bee', c: 'see'));The old buildRootPages / skeleton flow still exists for migration.
For that case, a location simply leaves builder.content unset. The route
stays in the tree while page construction still happens in buildRootPages.
Add build_runner to your app's dev_dependencies, then run:
flutter pub run build_runner build --delete-conflicting-outputsOr during development:
flutter pub run build_runner watch --delete-conflicting-outputsThe package example demonstrates:
- the splash -> a -> ab/abc and ad/adc flow
- a responsive route tree where small screens stack on top of
/awhile medium/large screens keep the alphabet sidebar visible in a shell - a
ShellLocationthat removes one nesting level from aShell + Locationpattern - lightweight callback-based
Locationwrappers plus an override-basedAbstractLocationexample - direct
Shell(...)andShellLocation(...)usage with optionalAbstractShell/AbstractShellLocationsubclassing support - typed path and query params
- generated
routeToX(...)helpers - generated
XRouteTarget(...)classes - generated owner-bound
childXTarget(...)helpers - a custom modal page from
builder.page = ...
Run it from example.