Skip to content
Blog

Navigator 2.0 Migration Guide: From Anonymous Routes to the Router

A practical path from Navigator 1.0 push/pop to RouterDelegate, RouteInformationParser, and deep-linkable Page objects.

Published on • October 20, 2026

AI Assistant

Navigator 1.0 — push, pop, anonymous MaterialPageRoutes — works fine until you ship a web build or need deep links to reconstruct state. Then the imperative stack fights you. Flutter’s navigation and routing docs now draw a sharp line: small apps use the Navigator; apps with deep linking or navigation requirements should use the Router. This is a migration path from one to the other.

Why migrate at all

The docs list concrete limitations of the named-route approach (the usual middle step):

  • Deep link behavior “can’t be customized” — when a platform deep link arrives, Flutter pushes a new Route regardless of where the user currently is. Deep-link into an app three screens deep and you get a fourth screen on top.
  • The browser forward button isn’t supported for apps using named routes.

The official recommendation today: don’t use named routes for most applications. Use go_router or Navigator with MaterialPageRoute. If you need full control, use the Router APIs directly.

Step 1: inventory your navigation

Audit three things before touching code:

  1. Anonymous pushes — Navigator.of(context).push(MaterialPageRoute(...)) calls. Each constructs a route from an inline builder, so nothing about it is reconstructible from a URL.
  2. Global navigation keys and pushNamed/pushNamedAndRemoveUntil — these encode navigation as imperative commands, not state.
  3. Navigation side effects — redirects after login, guards, analytics hooks scattered across call sites.

Step 2: decide your target

You have two legitimate destinations:

A routing package (go_router). The docs’ first recommendation. Declarative routes, the router constructor on MaterialApp, and context.go('/second') for navigation. You get deep linking, the browser History API, and redirect guards without writing parser code.

Hand-rolled Router. The docs’ “advanced developers” path: override RouteInformationParser and RouterDelegate, and control the screen stack precisely by supplying a list of Page objects through Navigator.pages. The router constructor on MaterialApp accepts the resulting configuration:

class AppRouterDelegate extends RouterDelegate<String>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<String> {
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  String? selectedId;

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        const MaterialPage(child: HomeScreen()),
        if (selectedId != null)
          MaterialPage(child: DetailScreen(id: selectedId!)),
      ],
      onDidRemovePage: (page) {
        selectedId = null;
        notifyListeners();
      },
    );
  }

  @override
  Future<void> setNewRoutePath(String configuration) async {
    selectedId = configuration;
  }
}

class AppRouteInformationParser extends RouteInformationParser<String> {
  @override
  Future<String> parseRouteInformation(RouteInformation routeInformation) async =>
      routeInformation.uri.path;

  @override
  RouteInformation? restoreRouteInformation(String configuration) =>
      RouteInformation(uri: Uri.parse(configuration));
}

Wire them into the app:

MaterialApp.router(
  routerDelegate: routerDelegate,
  routeInformationParser: parser,
)

Routing packages like go_router wrap exactly these classes — reading them explains every abstraction in the package.

Step 3: convert pushes into pages

The core conceptual shift: stop commanding the navigator and start describing it. State owns the route set; the build method renders it as Page objects. Every anonymous push becomes a state mutation plus a page appearing in the list.

Understand the page-backed/pageless distinction the docs define, because it governs deep linking:

  • Routes created from Page objects via Navigator.pages are page-backed and always deep-linkable.
  • Routes created by Navigator.push or showDialog are pageless — not deep-linkable.
  • When a page-backed route is removed, all pageless routes after it are removed too.
  • You can’t block navigation away from page-backed screens with PopScope — that concern moves to your routing layer (consult your package’s docs).

Dialogs and bottom sheets legitimately stay pageless; that’s fine.

Step 4: get deep linking right

With the Router in place, deep-link behavior changes from “push whatever arrived” to “parse the URL and configure the page list accordingly.” The docs’ behavior table shows why this matters: with Router, the incoming path goes through RouteInformationParser and RouterDelegate.setNewRoutePath, which configures the Navigator with the corresponding Pages — including replacing the current set while the app is running. With the old Navigator approach, pushRoute just stacks a screen.

Two migration details from the deep linking page:

  • If you previously wrote a plugin to handle deep links, opt out of Flutter’s built-in handler: set FlutterDeepLinkingEnabled to false in Info.plist or flutter_deeplinking_enabled to false in AndroidManifest.xml.
  • On web, no extra setup is needed — route paths work like iOS/Android deep links. Default reads the path from the URL fragment (/#/path), changeable via the URL strategy config.

Step 5: verify the browser contract

Router-integrated apps add a History API entry on every navigation, and the back button uses reverse chronological navigation: pop a page, press browser back, and the page is pushed back on. Test this deliberately — it’s the behavior named routes couldn’t support and the main reason web users notice the difference.

Migration tactics that reduce risk

  • Migrate route by route. Page-backed and pageless routes coexist on one Navigator, so you can convert your deep-linkable screens first and leave leaf dialogs alone.
  • Move guards into the parser. Auth redirects belong in parseRouteInformation/setNewRoutePath, not scattered pushReplacement calls.
  • Keep one navigation API. Mixed push + context.go in the same feature is where state desync bugs come from.

The Router API design doc (flutter.dev/go/navigator-with-router) and the “Learning Flutter’s new navigation and routing system” blog post go deeper on the raw API; the docs point most app teams at go_router for good reason.

Sources