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
- Basic Routing
- Route Parameters
- Query Parameters
- Named Routes
- Resource Routes
- Route Groups
- Standalone Layouts
- Context-Free Navigation
- Route Middleware
- URL Strategy
- Navigator Observers
- Page Titles
- Router Config
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
:paramsyntax (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
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'}
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'});
[!NOTE] Query parameters are always
Stringvalues. 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');
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 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 onMagicRoute.group()is more convenient. UseMagicRoute.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()andMagicRoute.toNamed().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!
}
}
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.
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 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());
// ...
}
}
Observers are passed directly to GoRouter and receive all navigation events (didPush, didPop, didReplace, didRemove).
[!NOTE] Observers must be registered before
routerConfigis accessed. Adding observers after the router is built throws aStateError.
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:
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, 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.