Skip to content

Routing

View raw .md

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.

On this page

Introduction#

The most basic Magic routes accept a URI and a closure, providing a very simple and expressive method of defining routes and behaviour 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:

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

Route Methods#

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

dart
// 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:

dart
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:

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

You may define as many route parameters as required:

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

Reading Parameters From a Widget#

MagicRouter.instance.pathParameter() answers from the page whose builder ran last, which is not always the page on screen. When a page is pushed across layout shells, go_router builds it before the shell underneath, so the static reader hands the pushed page the parameters of the page beneath it. A widget that has a BuildContext should ask the page it sits in instead:

dart
class _ThreadViewState extends State<ThreadView> {
  String? _id;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _id = MagicRouter.instance.pathParameterOf(context, 'id');
  }
}

queryParameterOf(context, key) is the query counterpart. Call both from didChangeDependencies or build, never initState: they subscribe the context to the router, which a context still being created cannot do. Both throw a GoError when no route page sits above the context (a widget pumped on its own), and neither falls back to the static reader, because that fallback is the wrong page's value. Keep pathParameter(), queryParameter() and the Request facade for code with no BuildContext, such as a controller or a middleware.

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:

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

Use Request.queryParams to retrieve all query parameters as a Map<String, String>:

dart
// 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:

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

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

Named Routes#

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

dart
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:

dart
// 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)
dart
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:

dart
class DocsController extends MagicController with ResourceController {
  @override
  Set<String> 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:

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

Prefixes#

Add a path prefix to all routes in a group:

dart
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:

dart
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, which suits tab bars, navigation rails and sidebars:

dart
MagicRoute.group(
  layoutId: 'app',
  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:

dart
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
        ],
      ),
    );
  }
}

One Group, One Shell

Every group with a layout: compiles to its own go_router ShellRoute. Groups that share a layoutId share one persistent shell: their routes are concatenated in registration order under the first builder. A group without a layoutId, or with a different one, is a separate shell, even when it builds the same layout widget:

dart
MagicRoute.group(
  layoutId: 'app',
  layout: (child) => AppLayout(child: child),
  middleware: ['auth'],
  routes: () {
    MagicRoute.page('/', () => DashboardView());
  },
);

MagicRoute.group(
  layoutId: 'app', // Same id, same shell as the group above.
  layout: (child) => AppLayout(child: child),
  prefix: '/billing',
  middleware: ['auth'],
  routes: () {
    MagicRoute.page('/', () => BillingView());
  },
);

Moving between two shells rebuilds the layout and plays its entrance animation again, so the sidebar flashes and loses its state. Put every route that shows the same layout in groups with one layoutId.

Shells are emitted in registration order of their first group, after every top-level route, and go_router tries them in that order: a top-level catch-all shadows every layout page. In debug builds, MagicRouter prints a note once per layout widget type when a group without a layoutId builds the same layout widget as another group (two different explicit ids are taken as deliberate), and suggests the same layoutId to share one shell or a unique id to keep them apart on purpose.

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:

dart
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. Layouts that share an id merge into one shell, exactly like groups sharing a layoutId; a layout without one is its own shell:

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

The routes you pass leave the top-level table and live under the layout only, as a group's routes do; an overlay route in the list is the exception and opens on the root navigator, above the shell (see Bottom Sheet Routes). go_router takes the first match, trying every top-level route first and then each shell in registration order. A catch-all such as MagicRoute.page('/:slug', ...) therefore captures every path it can match that is tried after it: at the top level it shadows every layout page, and last in an app shell it still captures /login from a guest shell registered later. Register a catch-all after every route it could shadow, in the last shell tried.

A Layer Above Every Page#

A layout belongs to its routes: a to() to an unstacked route outside the group disposes it with them. When something has to survive every navigation, whatever the route, wrap the router itself with MagicApplication's builder:

dart
MagicApplication(
  builder: (context, child) => Stack(
    children: [
      child!,
      const FloatingPlayer(),
    ],
  ),
)

It is passed straight to MaterialApp.builder, so the builder runs inside the app's Theme, localisations, Directionality and MediaQuery, and child is the Router, which builds the root Navigator. Whatever you put around child sits above the Navigator and inside no route, so its State survives every to(), push and back(). That is the property a floating video player needs, since remounting its platform view would restart the stream.

The same position means Navigator.of(context) and Overlay.of(context) find nothing from the layer's own context. Navigate with MagicRoute and open a dialog with Magic.dialog(), which shows it on the root Navigator. A widget inside the layer that needs an Overlay of its own, such as a Tooltip, a WPopover or a WSelect, throws unless the layer puts an Overlay around it.

Only the running app is wrapped: the loading and failure screens shown before MagicApplication finishes initialising are not. Leaving builder null changes nothing.

Context-Free Navigation#

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

dart
// 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, with no setup. 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:

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

From Controllers#

dart
class AuthController extends MagicController {
  Future<void> 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():

dart
// 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.

On the web a stacked route owns the address bar: opening /monitors/42 shows /monitors/42, browser back returns to /monitors, and a reload reopens the monitor. go_router reports a push under the address of the page beneath it by default, so the router turns on GoRouter.optionURLReflectsImperativeAPIs when it is built. go_router discourages that flag because a pushed page's URL is not always deep-linkable; every Magic route is a full path the router matches on its own, so the address always matches a route. It applies to every push, not only stacked routes: MagicRoute.push(), replace() over a pushed page and a go_router context.push report their own address as well. A reload arrives with only the path, so a page pushed with go_router's extra gets no extra back, and the page that was underneath is gone, so back() needs a fallback there. The flag is global to go_router, so an app that sets it back to false after Magic.init() gets the list's address again.

On the web, MagicRoute.back() closes a pushed page through the browser's own Back instead of adding an entry. go_router reports every change of the page stack to the browser as a new entry, so back() from /monitors/42 used to leave [/monitors, /monitors/42, /monitors] behind and the next browser Back reopened the monitor. Now the history stays [/monitors, /monitors/42] with /monitors current, and Forward reopens the monitor. A second back() before the browser has answered does nothing, and a to(), push() or replace() issued meanwhile runs once it has, so MagicRoute.back(); MagicRoute.to('/monitors/7') lands on /monitors/7 with the history [/monitors, /monitors/7]. If the browser does not answer within a second the step is given up and the page stays open (a sheet a drag slid shut slides back up); nothing closes in the app then, so an answer the browser sends late closes the page like its own Back instead of stepping past an entry the app just added.

The router steps back only when it can vouch for the entry before: the page was pushed by to() or push() while the browser's current entry held exactly the stack beneath it, and the browser is still on the entry that push created (compared by its full encoded stack, page keys included, not only its address). A page whose top was swapped by replace(), including the query swap to() does on the screen already showing, gives up that claim: go_router keeps the page key and reports the swap as a new entry, so the entry before is the page as it was. Every other close happens in the app and adds an entry the old way, so the closed page can sit behind browser Back, one entry back, rather than the router stepping into an entry it cannot vouch for:

  • Navigator.pop and Navigator.popUntil from app code, which keep go_router's synchronous pop on every route except a guarded bottom sheet;
  • a page pushed by a go_router context.push, or two pushes in one frame (they share one entry, so neither owns it);
  • any page after the app closed a page in the app, since the entry before no longer holds the stack beneath.

A refresh of the router (refreshListenable, the auth state notifier) does not re-run the redirect of a page pushed on top of the stack: go_router re-parses the browser's entry from the address of the stack's base, so a stacked page whose middleware now redirects stays on screen until the next navigation. That is go_router's behaviour on every platform, not something the web history work changed.

No route gains a go_router onExit for this; magic intercepts only the closes it drives itself. Measured in Chrome; Safari, Firefox and mobile browsers were not measured.

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 (pathParameterOf and queryParameterOf read the page's own state and are not affected). 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:

dart
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', query: {'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.

"The path you are already on" is the top of the page stack, read the moment to() is called: over a page inside a layout, and for a second to() in the same frame (a double click), it is the pushed screen, so the swap and the re-tap rules above hold there too.

Set the default once when a whole app wants it:

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

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#

dart
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.

dart
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:

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

It is applied with copyWith, so your wind_kit theme's colours, 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.

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

Bottom Sheet Routes#

A route marked overlay() opens as a modal bottom sheet over a page instead of replacing it, and keeps its own URL:

dart
MagicRoute.page('/stock', () => StockPage()).title('Stock');

MagicRoute.page('/stock/add', () => AddItemSheet())
    .title('Add item')
    .overlay(base: '/stock', canDismiss: () => !form.isDirty);
  • MagicRoute.to('/stock/add') always PUSHES the sheet over the page on screen, whoever calls it: a deep link that magic_deeplink routes through to(), or the redirect back to an intended URL after login, opens the sheet over whatever page is current. Closing it, MagicRoute.back(), the Android back button and browser Back return to that page with its state intact. On the web the address bar shows /stock/add while the sheet is open. Calling to() again for a sheet that is already on top does nothing, so a double click opens one sheet; MagicRoute.push() has no such guard.
  • A location that replaces the whole stack builds base first and opens the sheet over it in the next frame, so closing it leaves a real page underneath. That is the initial location, route information the platform reports (a reload, a typed address, a link the platform hands to the router) and a go_router go(). On the web the sheet waits until the browser holds an entry for base, so a page loaded straight on /stock/add leaves the history as [/stock, /stock/add]: the first browser Back closes the sheet onto /stock and the second leaves the app. That is best effort on Chrome until the reader has interacted with the page: Chrome's history intervention lets its real Back button skip an entry a script added without a user gesture, so a cold load nobody touched may still leave the app on the first Back. The Chrome measurement drove Back through Playwright, whose goBack does not go through that rule, so it does not prove the untouched case. A reload with the sheet open is a cold entry too, and since the browser hands a reloaded page only its path, the entry it reloaded becomes /stock and the sheet gets a new one, so the history then holds /stock twice and one Back in between shows no change (measured in Chrome). MagicRoute.replace('/stock/add') is not such an entry: it swaps the top page for the sheet, and when that page is the only one, nothing is left underneath.
  • base is the full path of another registered page route (group prefix included). It may declare parameters, which a cold entry fills from the overlay's own location, so the overlay must declare each of them: /locations/:id/count over /locations/:id opens on location 42 for /locations/42/count. The router refuses to build when base names no route, names another overlay, or needs a parameter the overlay path lacks.
  • canDismiss is asked before every close: a scrim tap, a drag down, the system back button, browser Back, MagicRoute.back(), and a to() that replaces the stack. A to() to a stacked() route pushes over the sheet and asks nothing. While it answers false the scrim and the drag are off and every other way out leaves the sheet open. It is read again on each attempt, so a guard that turns while the sheet is open is honoured; a drag that was already allowed slides the sheet back up. Browser Back refused this way keeps the sheet and the address bar stays on the sheet's URL (measured in Chrome).
  • On the web, closing the sheet from inside the app steps the browser history back like the stacked pages in Back Gestures and the Stack, under the same conditions, when the close is its own button calling MagicRoute.back(), a scrim tap or a drag. A later browser Back then leaves /stock instead of reopening the sheet, and Forward reopens it. A button that calls Navigator.pop closes in the app and adds an entry, so a later browser Back reopens the sheet: close with MagicRoute.back(), and to leave the sheet for another page write MagicRoute.back(); MagicRoute.to('/stock/42'), which lands on /stock/42 with the sheet's entry gone. Measured in Chrome for each close, for a sheet over a stacked page, and after a refused Back once the guard let go; Safari, Firefox, mobile browsers and the Android system back button were not measured.
  • A panel that refuses the pop itself (PopScope(canPop: false), a Form with canPop: false, a local history entry) keeps it: a scrim tap or Escape then leaves the sheet open and the browser history untouched, and the panel's own onPopInvokedWithResult runs once, as it would without the router. A drag is a Navigator.pop and is not stopped by PopScope, as with any Flutter bottom sheet; use canDismiss to stop it.
  • A panel built on DraggableScrollableSheet(shouldCloseOnMinExtent: true) that closes by shrinking to its minimum closes in the app and adds an entry the old way: BottomSheet closes it from the extent change, not from a drag-end fling, which is what the router recognises.
  • A guarded sheet (one with canDismiss) takes a go_router onExit, on every platform, so go_router finishes each pop of it in a microtask. Two consequences: a Navigator.popUntil over the open sheet never gets past it and must not be used, and every to(), push() or replace() issued while the sheet is on top runs one event later, whether or not a pop came first (and waits further for a browser step back started meanwhile), so Navigator.pop(); MagicRoute.to('/places/5') lands on /places/5 instead of go_router dropping it with the closed sheet. A second pop of the same sheet in one event is refused.

The sheet is a frame only: a ModalBottomSheetRoute on the root navigator with a transparent background, isScrollControlled, no drag handle, no safe-area padding and no constraints, so Material 3's 640dp width cap applies. That is the frame showModalBottomSheet(backgroundColor: Colors.transparent, isScrollControlled: true, useRootNavigator: true) builds, so a panel widget that draws its own surface, handle and safe-area padding gets the same frame opened by URL or by hand. Whatever the route builds is the panel. A route page has no caller context to capture themes from, so the sheet inherits the app-level themes, not a Theme or DefaultTextStyle that a layout sets for its own pages.

An app can open its overlays as centred dialogs instead, typically on a wide window, by installing a resolver once in a service provider:

dart
MagicRoute.useOverlayPresentation(
  (context) => MediaQuery.sizeOf(context).width >= 1024
      ? MagicOverlayPresentation.dialog
      : MagicOverlayPresentation.sheet,
);

The resolver is asked once each time an overlay opens, with the root navigator's context; the answer holds until that overlay closes, so resizing the window does not swap an open frame. Without a resolver every overlay is a sheet. Magic itself knows no breakpoint: the decision is the app's. The dialog is a frame as well: a DialogRoute on the root navigator with the sheet's scrim colour, no safe-area padding and no captured themes. Like the sheet's BottomSheet, it puts a Material above the panel, a transparent one, so a bare Text or WText in the panel gets the theme's text style instead of the debug fallback (a yellow double underline). That Material paints nothing and takes no tap of its own, so the scrim around the panel still closes the dialog; the panel still draws its own dialog surface and size. Everything above except the drag applies to it unchanged: the URL, the cold entry, canDismiss (a guard that says no turns the scrim and Escape off) and the browser history steps. MagicRouter.reset() drops the resolver with the rest of the router.

An overlay declared inside a layout group keeps the group's prefix and middleware but never renders inside the layout: it opens above the shell, scrim and all. It is also matched BEFORE the layout's pages, which is what lets it win over a catch-all such as /:path(.*) declared last in the group, and it means an overlay path must not also match a page path: an overlay at /items/:id would capture a /items/new page. Route middleware and titles behave as on any page: redirectTarget runs first (a guard that redirects wins over building the base), handle() runs when the sheet mounts, and the title follows the sheet while it is open. transition(), stacked() and swipeBack() do not apply to an overlay.

Route Middleware#

Assign middleware to individual routes using the middleware method:

dart
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, with no code changes needed elsewhere.

URL Strategy (Path vs Hash)#

Set url_strategy in your config/routing.dart:

dart
'routing': {
  'url_strategy': 'path', // 'path' | 'hash' | null (default: null, the 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'.

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:

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

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

dart
class ScreenLogObserver extends NavigatorObserver {
  @override
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
    Log.debug('Screen: ${route.settings.name}');
  }
}

class RouteServiceProvider extends ServiceProvider {
  @override
  Future<void> boot() async {
    // Add observers before registering routes
    MagicRouter.instance.addObserver(ScreenLogObserver());

    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:

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

Sentry's navigator observer is registered for you by the magic_sentry plugin (flutter pub add magic_sentry, then dart run magic:dowel plugin:install magic_sentry); see its Boot and Provider page.

Pages inside a layout are named the same way and reach the same observers. Layouts compile to ShellRoute, whose children push onto the shell's own nested navigator, not the root one. go_router still forwards that navigator's events to the observers you passed to the router, so they see every page.

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

Page Titles#

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

On Android the switcher card also carries a colour, and on web the same value becomes the theme-color meta. MagicApplication hands its theme's primaryColor to TitleManager.instance.setPrimaryColor(), the colour MaterialApp uses, so once the app has built a title change keeps that colour.

Chrome keeps a tab title per history entry, and the Flutter engine swaps entries with history.pushState or replaceState after a page builds, which used to leave the tab showing the previous entry's title or the bare host (app.example.com) while document.title was correct. MagicApplication re-sends the title after every history entry change on the web (through the Navigation API), so nothing is needed in your app. Other platforms, and browsers without the Navigation API, are unaffected.

Title Suffix#

Set a global suffix via MagicApplication:

dart
MagicApplication(
  title: 'My App',
  titleSuffix: 'Acme',
)

Page titles render as "Dashboard - Acme". 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:

dart
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:

dart
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):

dart
// 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<Object>, suitable for passing directly to MaterialApp.router (MaterialApp comes from package:material_ui/material_ui.dart):

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

MagicRoute.config builds the GoRouter on its first read; Magic.init() reads it once at the end of the bootstrap, after every provider has booted. Read it only after Magic.init() completes: an earlier read does not throw, it freezes the router with the routes registered so far, and every later route, layout or observer registration (addRoute, addLayout, addObserver) throws a StateError.

Gotchas#

Mistake Fix
Reading Request.query() once in initState() A query change does not remount the screen; read it in build() (or through queryParameterOf(context, key))
A const page widget in MagicRoute.page('/search', () => const SearchPage()) It hands back one identical instance, so nothing rebuilds on a query change; take a path parameter or read the query in build()
Using MagicRouter.instance.pathParameter() from a widget It answers for the page that built last, which may sit beneath the one on screen; use pathParameterOf(context, key) from didChangeDependencies or build
Expecting a swipe back or a working Android back button on a to() route to() replaces the page list; mark drill-down routes with .stacked() and .transition(RouteTransition.platform)
Adding a NavigatorObserver after the router is built addObserver throws a StateError; register observers in boot(), before routerConfig is read
Giving two groups the same layout widget but no shared layoutId Each group compiles to its own shell, so moving between them rebuilds the layout; give the groups one layoutId
Wrapping a dialog overlay panel in a full-frame Material to fix its text style The dialog already puts a transparent Material above the panel; add a Material only where the panel needs its own surface, and keep it off the scrim around the panel
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.