search ESC

Searching…

No results for "".

Type at least 2 characters to search.

Docs

Routing

Magic's routing wraps go_router with a Laravel-style fluent API: define routes with MagicRoute.page(), group them with middleware and layouts, and navigate without BuildContext.

Introduction

The most basic Magic routes accept a URI and a closure, providing a very simple and expressive method of defining routes and behavior without complicated routing configuration files.

All routes for your application are defined in the lib/routes directory. These files are loaded by the RouteServiceProvider, which is included in your config/app.dart by default.

Basic Routing

The most basic route definitions involve calling a method on the MagicRoute facade:

MagicRoute.page('/', () => HomePage());

Route Methods

Use the page method to define full-screen page routes:

// Simple page
MagicRoute.page('/greeting', () => Text('Hello World'));

// Controller action
MagicRoute.page('/dashboard', () => DashboardController.instance.index());

// Inline widget
MagicRoute.page('/about', () => AboutPage());

The Initial Route

Configure your application's initial route via MagicApplication:

runApp(
  MagicApplication(
    initialRoute: '/dashboard',
    // ...
  ),
);

Route Parameters

Required Parameters

Sometimes you need to capture segments of the URI. For example, to capture a user's ID:

MagicRoute.page('/user/:id', (id) {
  return UserProfileView(userId: id);
});

You may define as many route parameters as required:

MagicRoute.page('/posts/:postId/comments/:commentId', (postId, commentId) {
  return CommentView(postId: postId, commentId: commentId);
});

[!NOTE] Magic uses :param syntax (like Express.js) instead of Laravel's {param} syntax.

Query Parameters

Query parameters are the key-value pairs that appear after the ? in a URL (e.g., /search?q=flutter&page=2). Magic provides the Request facade to read them from the current route.

Reading Query Parameters

Use Request.query() to retrieve a single query parameter by key. It returns null when the key is absent:

// URL: /search?q=flutter&page=2
final term = Request.query('q');    // 'flutter'
final page = Request.query('page'); // '2'
final sort = Request.query('sort'); // null

[!IMPORTANT] Read the query where a REBUILD can see it, which means build() and not initState(). Navigating from /search?q=a to /search?q=b does not remount the screen: go_router keys a page on the matched path and the query is not part of it, so the same State is reused and initState never runs a second time. A screen that reads its query once at mount renders the first one forever while the address bar shows the second.

The same applies to a const page widget. MagicRoute.page('/search', () => const SearchPage()) hands back one identical instance every time, so the element never updates and nothing rebuilds at all. Take the path parameter ((id) => SearchPage(id: id)) or read the query in build().

Use Request.queryParams to retrieve all query parameters as a Map:

// URL: /search?q=flutter&sort=desc
final params = Request.queryParams;
// {'q': 'flutter', 'sort': 'desc'}

Pass a query map to MagicRoute.to() or MagicRoute.toNamed() to append query parameters to the URL:

// By path
MagicRoute.to('/search', query: {'q': 'flutter'});

// By name
MagicRoute.toNamed('search', query: {'q': 'flutter', 'page': '2'});

[!NOTE] Query parameters are always String values. Convert to other types after reading (e.g., int.tryParse(Request.query('page') ?? '')).

Named Routes

Named routes allow convenient generation of URLs or redirects for specific routes. Specify a name by chaining the name method:

MagicRoute.page('/user/profile', () => ProfileView())
    .name('profile');

MagicRoute.page('/user/:id', (id) => UserView(id: id))
    .name('user.show');

Once you have assigned a name to a route, you may use it when navigating:

// Navigate to named route
MagicRoute.toNamed('profile');

// With path parameters
MagicRoute.toNamed('user.show', params: {'id': '42'});

// With query parameters
MagicRoute.toNamed('search', query: {'q': 'flutter'});

Resource Routes

MagicRoute.resource() wires the four canonical GET routes for a resource in a single line. The target controller must mix in ResourceController and implement the view-building methods it supports.

Path Method
/{name} index()
/{name}/create create()
/{name}/:id show(id)
/{name}/:id/edit edit(id)
class MonitorController extends MagicController with ResourceController {
  @override
  Widget index() => const MonitorsIndexView();

  @override
  Widget create() => const MonitorCreateView();

  @override
  Widget show(String id) => MonitorShowView(id: id);

  @override
  Widget edit(String id) => MonitorEditView(id: id);
}

// Register all four routes at once
MagicRoute.resource('monitors', MonitorController.instance);

// Only a subset
MagicRoute.resource(
  'status-pages',
  StatusPagesController.instance,
  only: ['index', 'show'],
);

// All except a few
MagicRoute.resource(
  'metrics-library',
  MetricsLibraryController.instance,
  except: ['create', 'edit'],
);

Controllers that only expose a subset can override resourceMethods:

class DocsController extends MagicController with ResourceController {
  @override
  Set get resourceMethods => const {'index', 'show'};

  @override
  Widget index() => const DocsIndexView();

  @override
  Widget show(String id) => DocsShowView(slug: id);
}

Each registered route receives the name and title key {slug}.{method} (for example monitors.index, monitors.show). Override with the usual fluent API when needed.

Mutating actions (store, update, destroy) stay as regular controller methods invoked via Http.post / put / delete. They are not routes.

Route Groups

Route groups allow you to share route attributes, such as middleware or prefixes, across multiple routes.

Middleware

Assign middleware to all routes within a group:

MagicRoute.group(
  middleware: ['auth'],
  routes: () {
    MagicRoute.page('/dashboard', () => DashboardView());
    MagicRoute.page('/profile', () => ProfileView());
  },
);

Prefixes

Add a path prefix to all routes in a group:

MagicRoute.group(
  prefix: '/admin',
  middleware: ['auth', 'admin'],
  routes: () {
    MagicRoute.page('/', () => AdminDashboard());       // /admin
    MagicRoute.page('/users', () => AdminUsers());      // /admin/users
    MagicRoute.page('/settings', () => AdminSettings()); // /admin/settings
  },
);

Nested Groups

Groups can be nested. Child groups inherit parent attributes:

MagicRoute.group(
  prefix: '/admin',
  middleware: ['auth'],
  routes: () {
    MagicRoute.group(
      prefix: '/users',
      routes: () {
        MagicRoute.page('/', () => UserList());     // /admin/users
        MagicRoute.page('/:id', (id) => UserShow(id: id)); // /admin/users/:id
      },
    );
  },
);

Layouts (Shell Routes)

Assign a persistent layout to all routes within a group. The layout persists while child pages change—perfect for tab bars, navigation rails, and sidebars:

MagicRoute.group(
  layout: (child) => AppLayout(child: child),
  middleware: ['auth'],
  routes: () {
    MagicRoute.page('/', () => DashboardView());
    MagicRoute.page('/monitors', () => MonitorsView());
    MagicRoute.page('/settings', () => SettingsView());
  },
);

Your layout widget should accept and render the child parameter:

class AppLayout extends StatelessWidget {
  final Widget child;
  
  const AppLayout({required this.child, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          AppSidebar(),
          Expanded(child: child), // Child pages render here
        ],
      ),
    );
  }
}

[!TIP] Use layouts for any UI that should persist across page navigation, such as sidebars, bottom navigation bars, or headers.

Standalone Layouts

When you need to define a persistent layout outside of a group() call, use MagicRoute.layout(). This is useful when you want to build layouts programmatically or pass a list of already-registered route definitions:

final dashboardRoutes = [
  MagicRoute.page('/dashboard', () => DashboardView()).name('dashboard'),
  MagicRoute.page('/monitors', () => MonitorsView()).name('monitors'),
  MagicRoute.page('/settings', () => SettingsView()).name('settings'),
];

MagicRoute.layout(
  builder: (child) => AppLayout(child: child),
  routes: dashboardRoutes,
);

Pass an optional id to identify the layout when multiple layouts exist at the same level:

MagicRoute.layout(
  id: 'admin-shell',
  builder: (child) => AdminLayout(child: child),
  routes: adminRoutes,
);

[!TIP] For most cases, the layout: parameter on MagicRoute.group() is more convenient. Use MagicRoute.layout() when you need to reference a list of routes that are built separately.

Context-Free Navigation

You may navigate from anywhere in your application—controllers, services, or pure Dart classes—without needing BuildContext:

// Replace current route
MagicRoute.to('/dashboard');

// Push onto navigation stack (back button works)
MagicRoute.push('/details');

// Go back
MagicRoute.back();

// Go back with an explicit fallback path
MagicRoute.back(fallback: '/home');

// Replace current route (no new history entry)
MagicRoute.replace('/home');

// With query parameters
MagicRoute.to('/search', query: {'q': 'flutter'});

Cross-Shell Back Navigation

MagicRoute.back() works reliably even when navigating across shell routes (layouts). Magic maintains a lightweight history stack automatically—no setup required. When the standard pop is not possible, it falls back to the last tracked history entry.

Pass an optional fallback path to control where navigation lands when the history stack is empty:

// Falls back to '/dashboard' if there is no navigation history
MagicRoute.back(fallback: '/dashboard');

[!NOTE] The history stack is populated automatically by MagicRoute.to() and MagicRoute.toNamed() on an UNSTACKED route. A stacked one records nothing, deliberately: the push itself is the record, and back() prefers the native pop, so an entry there would leave a string naming the location the pop just landed on and make the next press look like a press that did nothing. replace() swaps the last entry without growing the stack, so back navigation after a replace lands at the entry before the replace.

From Controllers

class AuthController extends MagicController {
  Future logout() async {
    await Auth.logout();
    MagicRoute.to('/login'); // No context needed!
  }
}

Back Gestures and the Stack

MagicRoute.to() calls go(), which REPLACES the Navigator's page list. That is the right default for a tabbed app and the reason two platform behaviours are off until you ask for them.

On iOS there is no left-edge swipe back, because a swipe pops a page and there is never more than one. On Android it is worse than a missing gesture: Flutter tells the platform whether the app handles back, with one page it answers no, and the embedder then unregisters its callback so the system back button LEAVES THE APP instead of going back.

Mark the routes a reader drills INTO with stacked():

// Switched between: leave these alone, or every tap grows the stack.
MagicRoute.page('/monitors', () => MonitorsPage());
MagicRoute.page('/incidents', () => IncidentsPage());

// Drilled into: pushed, poppable, and back now means back.
MagicRoute.page('/monitors/:id', (id) => MonitorPage(id))
    .stacked()
    .transition(RouteTransition.platform);

back() is unchanged and still prefers the native pop, so the history fallback keeps covering every route you do not stack.

One exception, and it is not one you can trigger by hand. A navigation issued before the Router widget has parsed a location replaces rather than pushes, whichever verb asked: to() on a stacked() route and MagicRoute.push() alike. go_router pushes onto routerDelegate.currentConfiguration, which is empty until the widget mounts, so pushing there leaves the delegate reporting an empty location that currentLocation, pathParameter and queryParameter all read afterwards. The realistic way in is a deeplink or a tapped push notification on a cold start, and there is nothing underneath to pop back to at that point anyway: a link arriving from outside the app is where the reader arrives, not somewhere they stepped to.

toNamed() answers the same way. It resolves the name to a location and hands it to to(), so one route behaves one way whichever verb reaches it:

MagicRoute.page('/monitors/:id', (id) => MonitorPage(id)).name('monitors.show').stacked();

MagicRoute.toNamed('monitors.show', params: {'id': '42'}); // pushed, like to()

The page a stacked route builds is MagicPlatformPage, exported for a test or a type check that needs the name. Nothing asks you to construct one: the router builds it from the route's own transition() and swipeBack().

Navigating to the path you are already on depends on whether you name a query:

From to(...) Result
/monitors/42 '/monitors/42' nothing; a re-tapped destination does not stack a screen on itself
/monitors/42?tab=checks '/monitors/42' nothing; naming no query is asking for the screen, not asking to clear its tab
/monitors/42?tab=overview '/monitors/42', queryParameters: {'tab': 'checks'} the top page is swapped, so the screen rebuilds with the new tab and the pages under it survive

That swap rebuilds rather than remounts, which is what every other navigation in Magic does with a query change. See Reading Query Parameters for where a screen has to read its query for that to be visible.

Set the default once when a whole app wants it:

// In a service provider's boot(), before the router is built.
MagicRouter.instance.defaultStacked = true;
MagicRouter.instance.defaultTransition = RouteTransition.platform;

[!NOTE] Leave defaultStacked off on web. go() already produces a working browser Back, and pushing adds Navigator pages on top of that.

Transitions

RouteTransition.platform is the one that carries gestures. It routes through Flutter's PageTransitionsTheme, so iOS and macOS get the Cupertino slide plus the edge swipe, Android gets predictive back, and Windows and Linux get the zoom.

The other values build a custom transition on a bare page route, which carries no gesture at all: Flutter installs the back-swipe detector inside the Cupertino transition rather than beside it. RouteTransition.none, the default, is an instant switch with no animation.

Value Animation Back gesture
none (default) none no
platform the running platform's yes
fade, slideRight, slideUp, scale as named, every platform no

A side menu needs no arbitration with the swipe, even though both live on the left edge. Flutter refuses the gesture on a route with nothing under it, so the detector never enters the gesture arena on a drawer's own screen; on a pushed route both are armed and the deeper one wins.

Turning the Gesture Off

MagicRoute.page('/checkout/payment', () => PaymentPage())
    .stacked()
    .transition(RouteTransition.platform)
    .swipeBack(false);

swipeBack(false) refuses the GESTURE and nothing else: the route is still popped by the Android back button, by a back button in your own chrome, and by MagicRoute.back().

When the answer is "this route should not be left yet at all", use PopScope instead. It covers every one of those, and Flutter's gesture already honours it, so you do not need both.

PopScope(
  canPop: !form.isDirty,
  child: EditMonitorPage(),
)

Changing What platform Looks Like

RouteTransition.platform has no animation of its own. It routes through Theme.of(context).pageTransitionsTheme, so leaving it alone gives every platform the animation its own operating system uses, which is the reason to reach for it. Most apps should stop here.

When one surface needs a different answer, MagicApplication takes the theme:

MagicApplication(
  pageTransitionsTheme: const PageTransitionsTheme(
    builders: {
      TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
      TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
      TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
    },
  ),
)

It is applied with copyWith, so your Wind theme's colors, typography and component defaults are untouched, and leaving it null changes nothing at all.

A per-route RouteTransition cannot express this, which is why the knob exists: the transition is chosen once at registration, and the case that comes up is the opposite shape, keep the mobile builds' native animation and give the desktop or web build none.

[!NOTE] A partial map is a partial override. A platform you leave out of builders keeps its OWN default rather than falling through to a shared one, so omitting TargetPlatform.iOS leaves the Cupertino slide and its edge swipe exactly where they were. What removes the gesture is naming iOS and giving it a different builder, since Flutter installs the swipe detector inside the Cupertino transition rather than beside it.

Nothing else is affected: fade, slideRight, slideUp and scale build their own animation explicitly and never consult the theme.

Route Middleware

Assign middleware to individual routes using the middleware method:

MagicRoute.page('/profile', () => ProfileView())
    .middleware(['auth']);

MagicRoute.page('/admin', () => AdminPanel())
    .middleware(['auth', 'admin']);

See the Middleware documentation for details on creating custom middleware.

URL Strategy

Flutter web defaults to hash-based URLs (/#/path). Magic can enable clean path-based URLs (/path) via config — no code changes needed elsewhere.

URL Strategy (Path vs Hash)

Set url_strategy in your config/routing.dart:

'routing': {
  'url_strategy': 'path', // 'path' | 'hash' | null (default: null — hash strategy)
},
Value URL shape Notes
null (default) https://example.com/#/dashboard No web server config required
'path' https://example.com/dashboard Web server must rewrite all paths to index.html
'hash' https://example.com/#/dashboard Explicit hash; identical to null

Magic applies usePathUrlStrategy() during bootstrap (before runApp) when the value is 'path'.

[!NOTE] This setting has no effect on iOS, Android, or desktop — it is web-only.

Web server rewrite requirement: when using path-based URLs, every path must serve your Flutter app's index.html. Without this, a direct visit to https://example.com/dashboard returns a 404 from the server. Example nginx configuration:

location / {
    try_files $uri $uri/ /index.html;
}

SQLite on web: Magic's SQLite layer requires web/sqlite3.wasm to be present in your Flutter web build. This is independent of the URL strategy but equally web-specific. If you use the database on web, ensure sqlite3.wasm is copied into your web/ directory and served correctly. See the Database documentation for setup details.

Register NavigatorObserver instances for analytics, monitoring, or performance tracking. Observers must be added before the router is built (typically in your RouteServiceProvider):

class RouteServiceProvider extends ServiceProvider {
  @override
  Future boot() async {
    // Add observers before registering routes
    MagicRouter.instance.addObserver(SentryNavigatorObserver(
      enableAutoTransactions: true,
      setRouteNameAsTransaction: true,
    ));

    MagicRouter.instance.addObserver(FirebaseAnalyticsObserver(
      analytics: FirebaseAnalytics.instance,
    ));

    registerAppRoutes();
  }

  void registerAppRoutes() {
    MagicRoute.page('/', () => HomePage());
    // ...
  }
}

Every page carries a name in its RouteSettings, which is what an observer reads to tell one screen from another. It is the route's .name() when you set one, and the route path otherwise:

MagicRoute.page('/orders', () => OrdersPage()).name('orders');  // -> 'orders'
MagicRoute.page('/settings', () => SettingsPage());             // -> '/settings'

[!NOTE] GoRoute.name and RouteSettings.name are different things, and only the second one reaches an observer. Anything that identifies screens depends on it: analytics, breadcrumb trails, and Sentry's Flutter Web release health, which starts a session only when it sees this value change.

Pages inside a layout are named the same way and reach the same observers. Layouts compile to ShellRoute, and because the shell does not take its own navigatorKey, its children push onto the root navigator that your observers are already watching.

Observers are passed directly to GoRouter and receive all navigation events (didPush, didPop, didReplace, didRemove).

[!NOTE] Observers must be registered before routerConfig is accessed. Adding observers after the router is built throws a StateError.

Page Titles

Magic provides automatic page title management via SystemChrome.setApplicationSwitcherDescription — updates the browser tab title on web and the app switcher description on mobile.

Title Suffix

Set a global suffix via MagicApplication:

MagicApplication(
  title: 'My App',
  titleSuffix: 'Kodizm.AI',
)

Page titles render as "Dashboard - Kodizm.AI". The suffix is only applied to route-level and override titles — when no page title is set, the fallback app title is shown without suffix.

Static Route Titles

Assign titles to routes using the fluent .title() method:

MagicRoute.page('/dashboard', () => DashboardPage())
    .name('dashboard')
    .title('Dashboard')
    .middleware(['auth']);

The title is set automatically when the route becomes active.

Dynamic Titles with MagicTitle Widget

For data-dependent titles that resolve after the route mounts, wrap your widget with MagicTitle:

class ProjectPage extends StatelessWidget {
  final String projectName;

  const ProjectPage({super.key, required this.projectName});

  @override
  Widget build(BuildContext context) {
    return MagicTitle(
      title: projectName,
      child: Scaffold(
        appBar: AppBar(title: Text(projectName)),
        body: ProjectContent(),
      ),
    );
  }
}

MagicTitle sets the title on mount, updates on rebuild, and clears on dispose (falling back to the route title).

Imperative Title API

Set or read the title from anywhere — controllers, services, callbacks:

// Set title imperatively
MagicRoute.setTitle('User Profile — John');

// Read the current title (without suffix)
final title = MagicRoute.currentTitle;

Title Resolution Priority

Highest to lowest:

  1. MagicTitle widget / MagicRoute.setTitle() — explicit override
  2. RouteDefinition.title() — static route title
  3. MagicApplication.title — app-level fallback

When a higher-priority source is cleared (e.g., MagicTitle disposes), the next level takes over automatically.

Router Config

MagicRoute.config exposes the underlying GoRouter instance as a RouterConfig, suitable for passing directly to MaterialApp.router:

@override
Widget build(BuildContext context) {
  return MaterialApp.router(
    routerConfig: MagicRoute.config,
    title: 'My App',
  );
}

MagicRoute.config is only accessible after Magic.init() completes (it is the GoRouter produced by the router pre-build step in the bootstrap lifecycle). Accessing it before initialization throws a StateError.