unrouter
A Flutter router that gives you routing flexibility: define routes centrally, scope them to widgets, or mix both - with browser-style history navigation.
Documentation
All sections below are collapsible. Expand the chapters you need.
- Features
- Install
- Quick start
- Routing approaches
- Layouts and nested routing
- Route patterns and matching
- Navigation and history
- Navigation guards
- Route blockers
- Navigator 1.0 compatibility
- State and params
- Link widget
- Web URL strategy
- Testing
- API overview
- Example app
- Troubleshooting
- Contributing
- License
Features
- Declarative routes via
Unrouter(routes: ...) - Widget-scoped routes via the
Routeswidget - Hybrid routing (declarative first, widget-scoped fallback)
- Nested routes + layouts (
Outletfor declarative routes,Routesfor widget-scoped) - URL patterns: static, params (
:id), optionals (?), wildcard (*) - Browser-style navigation: push/replace/back/forward/go
- Async navigation results via
Navigation(awaitable) - Navigation guards with allow/cancel/redirect
- Route blockers for back/pop confirmation
- Navigator 1.0 compatibility for overlays and imperative APIs (
enableNavigator1, defaulttrue) - Web URL strategies:
UrlStrategy.browserandUrlStrategy.hash - Relative navigation with dot segment normalization (
./,../)
Install
Add to pubspec.yaml:
dependencies:
unrouter: ^0.3.0
Quick start
Declarative routing setup:
import 'package:flutter/material.dart';
import 'package:unrouter/unrouter.dart';
final router = Unrouter(
strategy: .browser,
routes: const [
Inlet(factory: HomePage.new),
Inlet(path: 'about', factory: AboutPage.new),
Inlet(
factory: AuthLayout.new,
children: [
Inlet(path: 'login', factory: LoginPage.new),
Inlet(path: 'register', factory: RegisterPage.new),
],
),
Inlet(
path: 'users',
factory: UsersLayout.new,
children: [
Inlet(factory: UsersIndexPage.new),
Inlet(path: ':id', factory: UserDetailPage.new),
],
),
Inlet(path: '*', factory: NotFoundPage.new),
],
);
void main() => runApp(MaterialApp.router(routerConfig: router));
Use Unrouter directly as an entry widget (no MaterialApp required):
void main() => runApp(router);
Routing approaches
Declarative routing (central config)
Unrouter(routes: [
Inlet(factory: HomePage.new),
Inlet(path: 'about', factory: AboutPage.new),
])
Widget-scoped routing (component-level)
Unrouter(child: Routes([
Inlet(factory: HomePage.new),
Inlet(path: 'about', factory: AboutPage.new),
]))
Hybrid routing (declarative first, widget-scoped fallback)
Unrouter(
routes: [Inlet(path: 'admin', factory: AdminPage.new)],
child: Routes([Inlet(factory: HomePage.new)]),
)
Hybrid routing also enables partial matches where a declarative route handles
the prefix and a nested Routes widget handles the rest.
Layouts and nested routing
Declarative layouts use Outlet
Layout and nested routes defined in Unrouter.routes must render an Outlet
to show matched children. Layout routes (path == '' with children) do not
consume a path segment.
class AuthLayout extends StatelessWidget {
const AuthLayout({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(body: Outlet());
}
}
Widget-scoped nesting uses Routes
class ProductsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: Routes([
Inlet(factory: ProductsList.new),
Inlet(path: ':id', factory: ProductDetail.new),
Inlet(path: 'new', factory: NewProduct.new),
]),
);
}
}
State preservation
unrouter keeps matched pages in an IndexedStack. Leaf routes are keyed by
history index, while layout/nested routes are cached by route identity to keep
their state when switching between children. Prefer const routes to maximize
reuse.
Route patterns and matching
Pattern syntax
- Static:
about,users/profile - Params:
users/:id,:userId - Optional:
:lang?/about,users/:id/edit? - Wildcard:
files/*,*
Route kinds
- Index:
path == ''andchildren.isEmpty - Layout:
path == ''andchildren.isNotEmpty(does not consume segments) - Leaf:
path != ''andchildren.isEmpty - Nested:
path != ''andchildren.isNotEmpty
Partial matching
Routes performs greedy matching and allows partial matches so nested
component routes can continue to match the remaining path.
Navigation and history
Imperative navigation (shared router instance)
router.navigate(.parse('/about'));
router.navigate(.parse('/login'), replace: true);
router.navigate.back();
router.navigate.forward();
router.navigate.go(-1);
Navigation results
All navigation methods return Future<Navigation>. You can ignore the result
or await it when you need to know what happened.
final result = await router.navigate(.parse('/about'));
if (result case NavigationRedirected()) {
// handle redirects
}
Navigation from any widget
context.navigate(.parse('/users/123'));
context.navigate(.parse('edit')); // /users/123/edit
context.navigate(.parse('./edit')); // /users/123/edit
context.navigate(.parse('../settings')); // /users/123/settings
Context extensions
context.navigate(.parse('/about'));
final router = context.router;
Relative navigation
Relative paths append to the current location and normalize dot segments. Query and fragment come from the provided URI and do not inherit.
Building paths
unrouter uses Uri as the first-class navigation input. You can build paths
directly with templates or Uri helpers:
final id = '123';
final uri = Uri.parse('/users/$id');
final withQuery = Uri(path: '/users/$id', queryParameters: {'tab': 'profile'});
context.navigate(withQuery);
Navigation guards
Guards let you intercept navigation and decide whether to allow, cancel, or redirect.
final router = Unrouter(
routes: const [Inlet(factory: HomePage.new)],
guards: [
(context) {
if (!auth.isSignedIn) {
return GuardResult.redirect(Uri.parse('/login'));
}
return GuardResult.allow;
},
],
);
You can also attach guards to specific declarative routes:
final routes = [
Inlet(
path: 'admin',
guards: [
(context) => GuardResult.redirect(Uri.parse('/login')),
],
factory: AdminPage.new,
),
];
Guards run in order: global guards first, then matched route guards from root to leaf. The first non-allow result (cancel/redirect) short-circuits.
Guards receive a GuardContext:
to: targetRouteInformationfrom: previousRouteInformationreplace: whether the navigation is a replaceredirectCount: number of redirects so far
You can return a Future<GuardResult> for async checks, and configure
maxRedirects to prevent redirect loops.
Route blockers
Route blockers intercept back/pop navigation (history go(-1) / back) and
let you confirm before leaving the current route. They run from child to parent.
class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RouteBlocker(
onWillPop: (ctx) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard changes?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Discard'),
),
],
),
);
return result == true;
},
onBlocked: (ctx) {
// Optional: record analytics or show a toast.
},
child: const SettingsForm(),
);
}
}
Notes:
- Blockers only apply to history back/pop (
navigate.back()/navigate.go(-1)). navigate.go(0)also triggers blockers.forwarddoes not trigger blockers.Navigator.popis still handled by Flutter'sWillPopScope/PopScope.
Route animations
unrouter provides a per-route AnimationController you can use to animate
incoming/outgoing pages without relying on Navigator 1.0.
class ProfilePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final animation = context.routeAnimation(
duration: const Duration(milliseconds: 300),
);
return FadeTransition(
opacity: animation,
child: const Text('Profile'),
);
}
}
The controller runs forward on push/replace and reverse on pop. Pages that
don't call routeAnimation behave as they do today.
The default duration is 300ms unless you provide one.
Navigator 1.0 compatibility
By default, Unrouter embeds a Navigator so APIs like showDialog,
showModalBottomSheet, showGeneralDialog, showMenu, and
Navigator.push/pop/popUntil work as expected.
final router = Unrouter(
enableNavigator1: true, // default
routes: const [Inlet(factory: HomePage.new)],
);
Set enableNavigator1: false to keep the Navigator 2.0-only behavior.
State and params
final state = context.routeState;
final uri = state.location.uri;
final params = state.params; // merged params up to this level
final extra = state.location.state; // history entry state (if any)
You can also read fine-grained fields (with narrower rebuild scopes):
final location = context.location;
final matched = context.matchedRoutes;
final params = context.params;
final level = context.routeLevel;
final index = context.historyIndex;
final action = context.historyAction;
RouteState.action tells you whether the current navigation was a push,
replace, or pop, and historyIndex can be used to reason about stacked pages.
Link widget
Basic link:
Link(
to: Uri.parse('/about'),
child: const Text('About'),
)
Custom link with builder:
Link(
to: Uri.parse('/products/1'),
state: {'source': 'home'},
builder: (context, location, navigate) {
return GestureDetector(
onTap: () => navigate(),
onLongPress: () => navigate(replace: true),
child: Text('Product 1'),
);
},
)
Web URL strategy
strategy: .browseruses path URLs like/about(requires server rewrites).strategy: .hashuses hash URLs like/#/about(no rewrites required).
UrlStrategy only applies to Flutter web. On native platforms
(Android/iOS/macOS/Windows/Linux), Unrouter uses MemoryHistory by default.
If you pass a custom history, strategy is ignored.
Testing
MemoryHistory makes routing tests easy:
final router = Unrouter(
routes: const [
Inlet(factory: HomePage.new),
Inlet(path: 'about', factory: AboutPage.new),
],
history: MemoryHistory(
initialEntries: [RouteInformation(uri: Uri.parse('/about'))],
),
);
Run tests:
flutter test
API overview
Unrouter: widget +RouterConfig<RouteInformation>(use directly or pass toMaterialApp.router)Inlet: route definition (index/layout/leaf/nested)Outlet: renders the next matched child route (declarative routes)Routes: widget-scoped route matcherNavigate: navigation interface (context.navigate)Navigation: async result returned by navigation methodsGuard/GuardResult: navigation interception and redirectsRouteState: current route state (read viacontext.routeState)History/MemoryHistory: injectable history (great for tests)Link: declarative navigation widget
Example app
See example/ for a complete Flutter app showcasing routing patterns and
Navigator 1.0 APIs.
cd example
flutter run
Troubleshooting
context.navigatethrows: ensure your widget is under anUnrouterrouter (eitherMaterialApp.router(routerConfig: Unrouter(...))orrunApp(Unrouter(...))).Routesrenders nothing: it must be a descendant ofUnrouter.showDialognot working: keepenableNavigator1: true(default).- Web 404 on refresh: use
strategy: .hashor configure server rewrites.
Contributing
- Format:
dart format . - Tests:
flutter test - Open a PR with a clear description and a focused diff
License
MIT - see LICENSE.