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.
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 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);
});
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:
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:
// 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>:
// URL: /search?q=flutter&sort=desc
final params = Request.queryParams;
// {'q': 'flutter', 'sort': 'desc'}
Navigating With Query Parameters#
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'});
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');
Navigating To Named Routes#
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<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:
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(
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:
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:
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, and go_router tries them in that order. 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:
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:
MagicRoute.layout(
id: 'admin-shell',
builder: (child) => AdminLayout(child: child),
routes: adminRoutes,
);
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:
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, localizations, 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 initializing 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:
// 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');
From Controllers#
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():
// 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.popandNavigator.popUntilfrom 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:
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.
"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:
// 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#
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.
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:
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 thatmagic_deeplinkroutes throughto(), 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/addwhile the sheet is open. Callingto()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
basefirst 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_routergo(). On the web the sheet waits until the browser holds an entry forbase, so a page loaded straight on/stock/addleaves the history as[/stock, /stock/add]: the first browser Back closes the sheet onto/stockand 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, whosegoBackdoes 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/stockand the sheet gets a new one, so the history then holds/stocktwice 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. baseis 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/countover/locations/:idopens on location 42 for/locations/42/count. The router refuses to build whenbasenames no route, names another overlay, or needs a parameter the overlay path lacks.canDismissis asked before every close: a scrim tap, a drag down, the system back button, browser Back,MagicRoute.back(), and ato()that replaces the stack. Ato()to astacked()route pushes over the sheet and asks nothing. While it answersfalsethe 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/stockinstead of reopening the sheet, and Forward reopens it. A button that callsNavigator.popcloses in the app and adds an entry, so a later browser Back reopens the sheet: close withMagicRoute.back(), and to leave the sheet for another page writeMagicRoute.back(); MagicRoute.to('/stock/42'), which lands on/stock/42with 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), aFormwithcanPop: 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 ownonPopInvokedWithResultruns once, as it would without the router. A drag is aNavigator.popand is not stopped byPopScope, as with any Flutter bottom sheet; usecanDismissto 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:BottomSheetcloses 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_routeronExit, on every platform, so go_router finishes each pop of it in a microtask. Two consequences: aNavigator.popUntilover the open sheet never gets past it and must not be used, and everyto(),push()orreplace()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), soNavigator.pop(); MagicRoute.to('/places/5')lands on/places/5instead 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:
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 only as well: a DialogRoute on the root navigator with the sheet's scrim colour, no safe-area padding and no captured themes, so the panel 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:
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'.
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.
Navigator Observers#
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<void> 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'
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 — updates the browser tab title on web and the app switcher description on mobile.
On Android the switcher card also carries a color, and on web the same value becomes the theme-color meta. MagicApplication hands its theme's primaryColor to TitleManager.instance.setPrimaryColor(), the color MaterialApp uses, so once the app has built a title change keeps that color.
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:
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:
MagicTitlewidget /MagicRoute.setTitle()— explicit overrideRouteDefinition.title()— static route titleMagicApplication.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:
@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.