zenrouter_file_generator 0.4.8
zenrouter_file_generator: ^0.4.8 copied to clipboard
A code generator for file-based routing in Flutter using zenrouter. Generate type-safe routes from your file/directory structure, similar to Next.js or Nuxt.js.
A code generator for file-based routing in Flutter using zenrouter. Generate type-safe routes from your file/directory structure, similar to Next.js, Nuxt.js or expo-router.
This package is part of the ZenRouter ecosystem and builds on the Coordinator paradigm for deep linking and web support.
Features #
- ποΈ File = Route - Each file in
routes/becomes a route automatically - π Nested layouts -
_layout.dartfiles define layout wrappers for nested routes - π Dynamic routes -
[param].dartfiles create typed path parameters - π Catch-all routes -
[...params].dartfiles capture multiple path segments - π¦ Route groups -
(name)/folders wrap routes in layouts without affecting URLs - π― Type-safe navigation - Generated extension methods for type-safe navigation
- π± Full ZenRouter support - Deep linking, guards, redirects, transitions, and more
- π Zero boilerplate - Routes are generated from your file structure
- πΈοΈ Lazy loading - Routes can be lazy loaded using the
deferredImportoption in the@ZenCoordinatorannotation. Improves app startup time and reduces initial bundle size.
Installation #
Add zenrouter_file_generator, zenrouter_file_annotation and zenrouter to your pubspec.yaml:
dependencies:
zenrouter: ^0.4.5
zenrouter_file_annotation: ^0.4.5
dev_dependencies:
build_runner: ^2.10.4
zenrouter_file_generator: ^0.4.5
Quick Start #
1. Create your routes directory structure #
Organize your routes in lib/routes/ following these conventions:
lib/routes/
βββ index.dart β /
βββ about.dart β /about
βββ (auth)/ β Route group (no URL segment)
β βββ _layout.dart β AuthLayout wrapper
β βββ login.dart β /login
β βββ register.dart β /register
βββ profile/
β βββ [id].dart β /profile/:id
βββ docs/
β βββ [...slugs]/ β Catch-all: /docs/a/b/c
β βββ index.dart β /docs/any/path
βββ tabs/
βββ _layout.dart β Layout for tabs
βββ feed/
β βββ index.dart β /tabs/feed
β βββ [postId].dart β /tabs/feed/:postId
βββ profile.dart β /tabs/profile
βββ settings.dart β /tabs/settings
2. Define routes with @ZenRoute #
// lib/routes/about.dart
import 'package:flutter/material.dart';
import 'package:zenrouter/zenrouter.dart';
import 'package:zenrouter_file_generator/zenrouter_file_generator.dart';
import 'routes.zen.dart';
part 'about.g.dart';
@ZenRoute()
class AboutRoute extends _$AboutRoute {
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('About')),
body: const Center(child: Text('About Page')),
);
}
}
3. Dynamic parameters with [param].dart #
Files named with brackets create dynamic route parameters:
// lib/routes/profile/[id].dart
import 'package:flutter/material.dart';
import 'package:zenrouter_file_annotation/zenrouter_file_annotation.dart';
import 'routes.zen.dart';
part '[id].g.dart';
@ZenRoute()
class ProfileIdRoute extends _$ProfileIdRoute {
ProfileIdRoute({required super.id});
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Profile: $id')),
body: Center(child: Text('User ID: $id')),
);
}
}
3.1 Catch-all parameters with [...params].dart #
Folders or files named with [...name] capture all remaining path segments as a List<String>. This is useful for:
- Documentation pages:
/docs/getting-started/installation - File paths:
/files/folder/subfolder/file.txt - Arbitrary nested routing:
/blog/2024/01/my-post-title
// lib/routes/docs/[...slugs]/index.dart
// Matches: /docs/any/number/of/segments
import 'package:flutter/material.dart';
import 'package:zenrouter_file_annotation/zenrouter_file_annotation.dart';
import 'routes.zen.dart';
part 'index.g.dart';
@ZenRoute()
class DocsRoute extends _$DocsRoute {
DocsRoute({required super.slugs}); // slugs is List<String>
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Docs: ${slugs.join('/')}'),
body: Center(
child: Column(
children: [
Text('Path segments: ${slugs.length}'),
for (final slug in slugs) Text('- $slug'),
],
),
),
);
}
}
Combining with other parameters
You can have additional routes inside a catch-all folder:
lib/routes/
βββ docs/
βββ [...slugs]/
βββ index.dart β /docs/a/b/c (catch-all)
βββ about.dart β /docs/a/b/c/about
βββ [id].dart β /docs/a/b/c/:id
// lib/routes/docs/[...slugs]/[id].dart
// Matches: /docs/any/path/user-123
@ZenRoute()
class DocsItemRoute extends _$DocsItemRoute {
DocsItemRoute({required super.slugs, required super.id});
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Item: $id')),
body: Text('In path: ${slugs.join('/')}'),
);
}
}
Generated pattern matching
The generator uses Dart's rest patterns for URL parsing:
// Generated parseRouteFromUri
AppRoute parseRouteFromUri(Uri uri) {
return switch (uri.pathSegments) {
['docs', ...final slugs] => DocsRoute(slugs: slugs),
['docs', ...final slugs, final id] => DocsItemRoute(slugs: slugs, id: id),
_ => NotFoundRoute(uri: uri),
};
}
// Generated navigation methods
extension AppCoordinatorNav on AppCoordinator {
Future<dynamic> pushDocs(List<String> slugs) =>
push(DocsRoute(slugs: slugs));
Future<dynamic> pushDocsItem(List<String> slugs, String id) =>
push(DocsItemRoute(slugs: slugs, id: id));
}
Note: Only one catch-all parameter is allowed per route. Routes with static segments are prioritized over catch-all routes during matching.
4. Layouts with _layout.dart #
Layouts wrap child routes in a common UI structure:
// lib/routes/tabs/_layout.dart
import 'package:flutter/material.dart';
import 'package:zenrouter_file_annotation/zenrouter_file_annotation.dart';
import 'routes.zen.dart';
part '_layout.g.dart';
@ZenLayout(
type: LayoutType.indexed,
routes: [FeedRoute, ProfileRoute, SettingsRoute],
)
class TabsLayout extends _$TabsLayout {
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
final path = resolvePath(coordinator);
return Scaffold(
body: buildPath(coordinator),
// You control the UI completely
bottomNavigationBar: BottomNavigationBar(
currentIndex: path.activePathIndex,
onTap: (i) => coordinator.push(path.stack[i]),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Feed'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'),
],
),
);
}
}
5. Run build_runner #
Generate the routing code:
dart run build_runner build
Or watch for changes:
dart run build_runner watch
6. Use in your app #
import 'package:flutter/material.dart';
import 'routes/routes.zen.dart';
final coordinator = AppCoordinator();
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerDelegate: coordinator.routerDelegate,
routeInformationParser: coordinator.routeInformationParser,
);
}
}
// Type-safe navigation with generated methods
coordinator.pushAbout(); // Push to /about
coordinator.pushProfileId('user-123'); // Push to /profile/user-123
coordinator.replaceIndex(); // Replace with home
coordinator.recoverTabProfile(); // Deep link to /tabs/profile
File Naming Conventions #
| Pattern | URL | Description |
|---|---|---|
index.dart |
/path |
Route at directory level |
about.dart |
/path/about |
Named route |
[id].dart |
/path/:id |
Dynamic parameter (single segment) |
[...slugs]/ |
/path/* |
Catch-all parameter (multiple segments, List<String>) |
_layout.dart |
- | Layout wrapper (not a route) |
_*.dart |
- | Private files (ignored) |
(group)/ |
- | Route group (layout without URL segment) |
Dot Notation #
You can also use dot notation in file names to represent directory nesting. This helps flatten your file structure while keeping deep URL paths.
parent.child.dart is equivalent to parent/child.dart.
Examples:
shop.products.[id].dartβ/shop/products/:idsettings.account.dartβ/settings/accountdocs.[version].index.dartβ/docs/:version
This is especially useful for grouping related deep routes without creating many nested folders.
Route Groups (name) #
Route groups allow you to wrap routes with a layout without adding the folder name to the URL path. This is useful for:
- Grouping related routes under a shared layout (e.g., auth flows)
- Organizing routes without affecting URL structure
- Applying different styling/themes to route groups
Example #
lib/routes/
βββ (auth)/ # Route group - wraps routes without URL segment
β βββ _layout.dart # AuthLayout - shared auth styling
β βββ login.dart β /login (NOT /(auth)/login)
β βββ register.dart β /register (NOT /(auth)/register)
βββ (marketing)/
β βββ _layout.dart # MarketingLayout
β βββ landing.dart β /landing
β βββ pricing.dart β /pricing
βββ dashboard/
βββ index.dart β /dashboard
Creating a Route Group Layout #
// lib/routes/(auth)/_layout.dart
import 'package:flutter/material.dart';
import 'package:zenrouter_file_annotation/zenrouter_file_annotation.dart';
import 'package:zenrouter/zenrouter.dart';
import '../routes.zen.dart';
part '_layout.g.dart';
@ZenLayout(type: LayoutType.stack)
class AuthLayout extends _$AuthLayout {
@override
Widget build(covariant AppCoordinator coordinator, BuildContext context) {
return Scaffold(
body: Container(
// Auth-specific styling (gradient, logo, etc.)
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [Colors.purple, Colors.blue]),
),
child: buildPath(coordinator),
),
);
}
}
Routes Inside Route Groups #
// lib/routes/(auth)/login.dart
import 'package:flutter/material.dart';
import 'package:zenrouter_file_annotation/zenrouter_file_annotation.dart';
import '../routes.zen.dart';
part 'login.g.dart';
// URL: /login (not /(auth)/login)
// Layout: AuthLayout
@ZenRoute()
class LoginRoute extends _$LoginRoute {
@override
Widget build(covariant AppCoordinator coordinator, BuildContext context) {
return Center(
child: Column(
children: [
TextField(decoration: InputDecoration(labelText: 'Email')),
TextField(decoration: InputDecoration(labelText: 'Password')),
ElevatedButton(
onPressed: () => coordinator.replaceIndex(),
child: const Text('Sign In'),
),
],
),
);
}
}
Generated Code #
The generator correctly handles route groups:
// Generated parseRouteFromUri
AppRoute parseRouteFromUri(Uri uri) {
return switch (uri.pathSegments) {
['login'] => LoginRoute(), // /login - wrapped by AuthLayout
['register'] => RegisterRoute(), // /register - wrapped by AuthLayout
['dashboard'] => DashboardRoute(),
_ => NotFoundRoute(uri: uri),
};
}
// Generated navigation methods
extension AppCoordinatorNav on AppCoordinator {
Future<dynamic> pushLogin() => push(LoginRoute());
Future<dynamic> pushRegister() => push(RegisterRoute());
}
Deferred Imports #
Improve your app's startup time by lazy-loading routes using deferred imports. When enabled, routes are only loaded when first navigated to, reducing initial bundle size.
Per-Route Configuration #
Enable deferred imports for individual routes:
@ZenRoute(deferredImport: true)
class HeavyRoute extends _$HeavyRoute {
// Route implementation
}
Global Configuration #
Enable deferred imports for all routes via build.yaml:
# In your project's build.yaml (not the package's build.yaml)
targets:
$default:
builders:
zenrouter_file_generator|zen_coordinator:
options:
deferredImport: true
Precedence Rules #
- Route annotation takes precedence:
deferredImport: falsein annotation overrides global config - IndexedStack routes are always non-deferred: Routes in
LayoutType.indexedcannot use deferred imports - Otherwise, global config applies: Routes without explicit annotation use the global setting
Example with Global Config #
# build.yaml
targets:
$default:
builders:
zenrouter_file_generator|zen_coordinator:
options:
deferredImport: true # All routes deferred by default
// Most routes use deferred imports automatically
@ZenRoute() // Uses global config (deferred)
class AboutRoute extends _$AboutRoute { }
// Explicitly disable for critical routes
@ZenRoute(deferredImport: false) // Override global config
class HomeRoute extends _$HomeRoute { }
// IndexedStack routes are always non-deferred
@ZenLayout(
type: LayoutType.indexed,
routes: [Tab1Route, Tab2Route], // Always non-deferred
)
class TabsLayout extends _$TabsLayout { }
Generated Code #
With deferred imports enabled:
// Generated imports
import 'about.dart' deferred as about;
import 'home.dart'; // Non-deferred (explicit or IndexedStack)
// Generated navigation
Future<void> pushAbout() async => push(await () async {
await about.loadLibrary();
return about.AboutRoute();
}());
Future<void> pushHome() => push(HomeRoute()); // No deferred loading
Performance Benchmarks #
Real-world benchmarks demonstrate significant initial bundle size reductions with deferred imports:
| Metric | Without Deferred | With Deferred | Improvement |
|---|---|---|---|
| Initial bundle | 2,414 KB | 2,155 KB | -259 KB (-10.7%) β |
| Total app size | 2,719 KB | 2,759 KB | +40 KB (+1.5%) |
| Deferred chunks | 0 | 24 chunks | - |
Key Benefits:
- β 10.7% faster initial load - Users see the app faster
- β On-demand loading - Routes load only when navigated to
- β Better caching - Unchanged routes won't re-download
- β οΈ Minimal overhead - Only 1.5% total size increase
Recommendation: For most applications, enabling deferred imports provides substantial initial load improvements with minimal trade-offs. The feature is especially effective for apps with many routes or large route components.
See the example's BENCHMARK_ANALYSIS.md for detailed measurements.
Route Mixins #
Enable advanced behaviors with annotation parameters:
import 'package:zenrouter/zenrouter.dart';
import 'package:zenrouter_file_annotation/zenrouter_file_annotation.dart';
@ZenRoute(
guard: true, // RouteGuard - control pop behavior
redirect: true, // RouteRedirect - conditional routing
deepLink: DeeplinkStrategyType.custom, // Custom deep link handling
transition: true, // RouteTransition - custom animations
queries: ['search', 'page'], // Query parameters
)
class CheckoutRoute extends _$CheckoutRoute {
@override
FutureOr<bool> popGuard() async {
return await confirmExit();
}
@override
FutureOr<AppRoute?> redirect() async {
if (!auth.isLoggedIn) return LoginRoute();
return null; // null means proceed with this route
}
@override
FutureOr<void> deeplinkHandler(AppCoordinator c, Uri uri) async {
c.replace(HomeRoute());
c.push(CartRoute());
c.push(this);
}
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
final searchTerm = query('search');
final page = query('page');
return CheckoutScreen(search: searchTerm, page: page);
}
}
Route Query Parameters #
You can easily handle query parameters with reactive updates using the queries parameter in @ZenRoute.
1. Enable Query Support #
// Enable all query parameters
@ZenRoute(queries: ['*'])
class SearchRoute extends _$SearchRoute { ... }
// OR enable specific parameters
@ZenRoute(queries: ['q', 'page', 'sort'])
class SearchRoute extends _$SearchRoute { ... }
2. Access and Watch Queries #
Use selectorBuilder to rebuild only when specific query parameters change, avoiding unnecessary rebuilds.
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Search Results')),
body: Column(
children: [
// Rebuilds ONLY when 'q' query param changes
selectorBuilder<String>(
selector: (queries) => queries['q'] ?? '',
builder: (context, searchTerm) {
return Text('Searching for: $searchTerm');
},
),
// ... rest of UI
],
),
);
}
3. Update Queries #
You can update queries without full navigation (preserving widget state where possible). The URL will be updated automatically.
// Update specific query param
updateQueries(
coordinator,
queries: {...queries, 'page': '2'},
);
// Clear all queries
updateQueries(coordinator, queries: {});
Layout Types #
Stack Layout (NavigationPath) #
For push/pop navigation:
@ZenLayout(type: LayoutType.stack)
class SettingsLayout extends _$SettingsLayout {
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: buildPath(coordinator),
);
}
}
Indexed Layout (IndexedStackPath) #
For tabs/drawers:
@ZenLayout(
type: LayoutType.indexed,
routes: [Tab1Route, Tab2Route, Tab3Route], // Order = index
)
class TabsLayout extends _$TabsLayout {
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
final path = resolvePath(coordinator);
return Scaffold(
body: buildPath(coordinator),
// Full control over navigation UI
bottomNavigationBar: YourNavigationWidget(
index: path.activePathIndex,
onTap: (i) => coordinator.push(path.stack[i]),
),
);
}
}
Generated Code Structure #
After running build_runner, your routes directory will look like:
lib/routes/
βββ index.dart # Your route class
βββ index.g.dart # Generated base class
βββ about.dart
βββ about.g.dart
βββ routes.zen.dart # Generated coordinator
Generated Coordinator #
The generator creates routes.zen.dart with:
AppRoutebase class (or custom name via@ZenCoordinator)AppCoordinatorclass withparseRouteFromUriimplementation- Navigation path definitions for layouts
- Type-safe navigation extension methods (push/replace/recover)
// routes.zen.dart (generated)
abstract class AppRoute extends RouteTarget with RouteUnique {}
class AppCoordinator extends Coordinator<AppRoute> {
final IndexedStackPath<AppRoute> tabsPath = IndexedStackPath([...]);
@override
List<StackPath> get paths => [root, tabsPath];
@override
AppRoute parseRouteFromUri(Uri uri) {
return switch (uri.pathSegments) {
[] => IndexRoute(),
['about'] => AboutRoute(),
['profile', final id] => ProfileIdRoute(id: id),
_ => NotFoundRoute(uri: uri),
};
}
}
// Type-safe navigation extensions
extension AppCoordinatorNav on AppCoordinator {
// Push, Replace, Recover methods for each route
Future<dynamic> pushAbout() => push(AboutRoute());
void replaceAbout() => replace(AboutRoute());
void recoverAbout() => recoverRouteFromUri(AboutRoute().toUri());
// Routes with parameters
Future<dynamic> pushProfileId(String id) => push(ProfileIdRoute(id: id));
void replaceProfileId(String id) => replace(ProfileIdRoute(id: id));
void recoverProfileId(String id) => recoverRouteFromUri(ProfileIdRoute(id: id).toUri());
}
Navigation Methods: Push / Replace / Recover #
For each route, the generator creates three type-safe navigation methods:
| Method | Return Type | Description |
|---|---|---|
push{Route}() |
Future<dynamic> |
Push route onto stack. Returns result when popped. |
replace{Route}() |
void |
Replace current route. No navigation history. |
recover{Route}() |
void |
Restore full navigation state from URI. For deep links. |
When to Use Each Method
push - Standard Navigation
// Navigate forward, user can go back
coordinator.pushAbout();
coordinator.pushProfileId('user-123');
// Wait for result when route pops
final result = await coordinator.pushCheckout();
if (result == 'success') { /* ... */ }
replace - Replace Current Route
// After login, replace login screen with home (no back button to login)
coordinator.replaceIndex();
// Switch tabs without adding to history
coordinator.replaceTabProfile();
recover - Deep Link / State Restoration
// Restore complete navigation state from a URI
// This rebuilds the entire navigation stack to reach the target route
coordinator.recoverProfileId('user-123');
// Equivalent to: coordinator.recoverRouteFromUri(Uri.parse('/profile/user-123'));
// Use for:
// - Deep links from external sources
// - App state restoration
// - Sharing URLs that should restore full navigation context
Example: Auth Flow
// On app start - check auth and recover appropriate state
if (isLoggedIn) {
coordinator.recoverIndex(); // Restore to home with full stack
} else {
coordinator.replaceLogin(); // Show login, no back navigation
}
// After successful login
coordinator.replaceIndex(); // Replace login with home
// User taps profile
coordinator.pushProfileId('current-user'); // Can go back to home
Example: Deep Link Handling
// When app receives deep link: myapp://profile/user-123
void handleDeepLink(Uri uri) {
// recover rebuilds navigation stack: [Home] -> [Profile]
coordinator.recoverProfileId('user-123');
}
Custom Coordinator Configuration #
Customize the generated coordinator by creating lib/routes/_coordinator.dart:
// lib/routes/_coordinator.dart
import 'package:zenrouter_file_annotation/zenrouter_file_annotation.dart';
@ZenCoordinator(
name: 'MyAppCoordinator',
routeBase: 'MyAppRoute',
)
class CoordinatorConfig {}
Integration with ZenRouter #
This package generates routes compatible with zenrouter's coordinator pattern:
- Routes extend
RouteTarget with RouteUnique - Layouts use
RouteLayoutmixin - Dynamic routes have typed parameters
- Full deep linking and URL synchronization support
- Route guards, redirects, and transitions
See the zenrouter documentation for more details on advanced features.
Example #
Check out the /example directory for a complete working example:
cd example
flutter pub get
dart run build_runner build
flutter run
License #
Apache License 2.0 - see LICENSE file for details.