ZenRouter Logo

ZenRouter File Generator

pub package Test Codecov - zenrouter

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.dart files define layout wrappers for nested routes
  • πŸ”— Dynamic routes - [param].dart files create typed path parameters
  • 🌟 Catch-all routes - [...params].dart files 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 deferredImport option in the @ZenCoordinator annotation. 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/:id
  • settings.account.dart β†’ /settings/account
  • docs.[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

  1. Route annotation takes precedence: deferredImport: false in annotation overrides global config
  2. IndexedStack routes are always non-deferred: Routes in LayoutType.indexed cannot use deferred imports
  3. 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:

  • AppRoute base class (or custom name via @ZenCoordinator)
  • AppCoordinator class with parseRouteFromUri implementation
  • 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());
}

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
// 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 RouteLayout mixin
  • 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.

Libraries

builder
Build runner builders for ZenRouter file-based routing.
zenrouter_file_generator
File-based routing generator for ZenRouter.