# 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](#introduction)
- [Basic Routing](#basic-routing)
- [Route Parameters](#route-parameters)
- [Query Parameters](#query-parameters)
- [Named Routes](#named-routes)
- [Resource Routes](#resource-routes)
- [Route Groups](#route-groups)
    - [Middleware](#middleware)
    - [Prefixes](#prefixes)
    - [Layouts (Shell Routes)](#layouts-shell-routes)
- [Standalone Layouts](#standalone-layouts)
- [A Layer Above Every Page](#a-layer-above-every-page)
- [Context-Free Navigation](#context-free-navigation)
- [Back Gestures and the Stack](#back-gestures-and-the-stack)
    - [Transitions](#transitions)
    - [Turning the Gesture Off](#turning-the-gesture-off)
    - [Changing What `platform` Looks Like](#changing-what-platform-looks-like)
- [Bottom Sheet Routes](#bottom-sheet-routes)
- [Route Middleware](#route-middleware)
- [URL Strategy](#url-strategy)
    - [URL Strategy (Path vs Hash)](#url-strategy-path-vs-hash)
- [Navigator Observers](#navigator-observers)
- [Page Titles](#page-titles)
- [Router Config](#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:

```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);
});
```

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

### 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
```

> [!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()`, through `MagicRouter.instance.queryParameterOf(context, 'q')` where the page may be pushed across layout shells.

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'}
```

### Navigating With Query Parameters

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'});
```

> [!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:

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

```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—perfect for 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, 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.

> [!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:

```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,
);
```

> [!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.

## 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`, 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.

> [!WARNING]
> A soft restart is not a navigation. `Magic.reload()` rebuilds the whole `MaterialApp`, so the layer is remounted with a new State, and `Lang.setLocale()` calls it unless you pass `reload: false`.

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

```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—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:

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

```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', 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](#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;
```

> [!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

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

## 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](#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 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:

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

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

See the [Middleware documentation](https://anilcancakir.com/magic/docs/v0.0/basics/middleware.md) 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`:

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

```nginx
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](https://anilcancakir.com/magic/docs/v0.0/database/getting-started.md) 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`):

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

```dart
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`, 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`).

> [!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.

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

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

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

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