Declarative, composable router for Flutter.
- 🧩 Nested Routes — Define route trees with
Inletand render child views viaOutlet - 🏷️ Named Routes — Navigate by route name with params, query, and state
- 🛡️ Guards — Navigation-time guards for allow/block/redirect decisions
- 📦 Route Meta — Attach arbitrary metadata to each route, inherited by children
- 🔗 Dynamic Params & Wildcards —
:idparams and*catch-all segments - 🔍 Query Params — First-class
URLSearchParamssupport - 📍 History API —
push,replace,pop,back,forward,go(delta) - ⚡ Reactive Hooks —
useRouter,useLocation,useRouteParams,useQuery,useRouteMeta,useRouteState,useFromLocation
dependencies:
unrouter: <latest>flutter pub add unrouterimport 'package:flutter/material.dart';
import 'package:unrouter/unrouter.dart';
final authGuard = defineGuard((context) async {
final token = context.query.get('token');
if (token == 'valid') {
return const GuardResult.allow();
}
return GuardResult.redirect('login');
});
final router = createRouter(
guards: [authGuard],
maxRedirectDepth: 8,
routes: [
Inlet(name: 'landing', path: '/', view: LandingView.new),
Inlet(name: 'login', path: '/login', view: LoginView.new),
Inlet(
path: '/workspace',
view: WorkspaceLayoutView.new,
children: [
Inlet(name: 'workspaceHome', path: '', view: DashboardView.new),
Inlet(name: 'profile', path: 'users/:id', view: ProfileView.new),
Inlet(name: 'search', path: 'search', view: SearchView.new),
],
),
Inlet(name: 'docs', path: '/docs/*', view: DocsView.new),
],
);routes supports multiple top-level Inlets. Use a single parent Inlet with Outlet only when views share the same layout.
For concise route definitions, prefer constructor tear-offs such as MyView.new.
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp();
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: createRouterConfig(router),
);
}
}class LayoutView extends StatelessWidget {
const LayoutView();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('My App')),
body: const Outlet(),
);
}
}Run the full example app:
cd example
flutter pub get
flutter run -d chromeSource entry points:
- Quickstart:
example/lib/quickstart/quickstart_app.dart - Advanced:
example/lib/advanced/advanced_app.dart
Inlet is the route-tree building block. Each Inlet describes a path segment, a view builder, optional children, guards, meta, and an optional route name.
Inlet(
name: 'profile',
path: 'users/:id',
view: ProfileView.new,
meta: const {'title': 'Profile', 'requiresAuth': true},
guards: [authGuard],
children: [/* nested Inlets */],
)| Property | Type | Description |
|---|---|---|
path |
String |
URI path segment pattern. Defaults to '/' |
view |
ViewBuilder |
() => Widget factory, typically MyView.new |
name |
String? |
Route name alias for navigation APIs |
meta |
Map<String, Object?>? |
Route metadata, merged with parent meta |
guards |
Iterable<Guard> |
Route-level guard chain |
children |
Iterable<Inlet> |
Nested child routes |
A guard runs before navigation is committed and returns one of three outcomes:
GuardResult.allow()GuardResult.block()GuardResult.redirect(pathOrName, {params, query, state})
final adminGuard = defineGuard((context) async {
final isAdmin = context.query.get('role') == 'admin';
if (isAdmin) {
return const GuardResult.allow();
}
return GuardResult.redirect(
'login',
query: URLSearchParams({'from': 'admin'}),
);
});Guard order is: global → parent → child.
- Redirects are re-validated by guards.
- Redirect commits use
replace. - Redirect depth is capped by
maxRedirectDepth(default8) to prevent infinite loops.
GuardContext provides navigation details:
from/to(HistoryLocation)action(HistoryAction.push,.replace,.pop)params(RouteParams)query(URLSearchParams)meta(Map<String, Object?>)state(Object?)
push/replace(pathOrName) resolves in this order:
- Try route name first
- If missing, fallback to absolute path
final router = useRouter(context);
await router.push('profile', params: {'id': '42'});
await router.push('/users/42?tab=posts');
await router.replace('landing');If both the input string and query argument contain query params, they are merged and explicit query entries override same-name keys.
await router.push(
'/search?q=old&page=1',
query: URLSearchParams({'q': 'flutter'}),
);
// => /search?q=flutter&page=1Link is a lightweight widget that triggers navigation.
Link(
to: 'profile',
params: const {'id': '42'},
child: const Text('Open Profile'),
)Supported props:
toparamsquerystatereplaceenabledonTapchild
Outlet renders the matched child view inside its parent. Every level of nesting requires an Outlet in the parent widget tree.
Meta is merged from parent to child routes.
Inlet(
view: Layout.new,
meta: const {'layout': 'dashboard'},
children: [
Inlet(
path: 'admin',
view: AdminView.new,
meta: const {'title': 'Admin', 'requiresAuth': true},
),
],
)Read meta in a widget:
final meta = useRouteMeta(context);final router = useRouter(context);
await router.pop();
router.back();
router.forward();
router.go(-2);
router.go(1);| Hook | Returns | Description |
|---|---|---|
useRouter(context) |
Unrouter |
Router instance |
useLocation(context) |
HistoryLocation |
Current location (uri + state) |
useRouteParams(context) |
RouteParams |
Matched :param values |
useQuery(context) |
URLSearchParams |
Parsed query string |
useRouteMeta(context) |
Map<String, Object?> |
Merged route metadata |
useRouteState<T>(context) |
T? |
Typed navigation state |
useRouteURI(context) |
Uri |
Current route URI |
useFromLocation(context) |
HistoryLocation? |
Previous location |
Unrouter createRouter({
required Iterable<Inlet> routes,
Iterable<Guard>? guards,
String base = '/',
int maxRedirectDepth = 8,
History? history,
HistoryStrategy strategy = HistoryStrategy.browser,
})RouterConfig<HistoryLocation> createRouterConfig(Unrouter router)Guard defineGuard(Guard guard)DataLoader<T> defineDataLoader<T>(
DataFetcher<T> fetcher, {
ValueGetter<T?>? defaults,
})