Declarative Routing in Flutter with Navigator 2.0 and GoRouter
Learn how Navigator 2.0 and GoRouter give Flutter apps declarative, URL‑driven routing with deep‑link support, authentication guards, and predictable back‑button behavior.
02 Nov 2025, 22:26 UTC

The problem: keeping UI and URL in sync
Flutter’s original Navigator API is imperative — you call push, pop, or pushReplacement and the framework updates the widget tree. On the web, however, the browser address bar, the back button, and deep links all expect the URL to be the single source of truth. When the two sources diverge, users see stale URLs, broken bookmarks, or navigation that doesn’t respect the browser’s history.
Navigator 2.0 solves this by making the router a delegate that rebuilds the page stack from a parsed RouteInformation object. The URL becomes the driver, not a side effect.
Core pieces you need to understand
RouterDelegate
Implements RouterDelegate<PageStack> and owns the list of Page objects that represent the current navigation state. Its build method returns a Navigator whose pages mirror that list.
RouteInformationParser
Converts the browser’s RouteInformation (essentially the URL and query string) into a configuration object your delegate can understand — often a simple data class like AppRoute.
Page subclasses
Each screen gets a Page subclass (e.g., MaterialPage, CupertinoPage) that holds the data needed to build the widget. Because pages are immutable, the delegate can replace the whole stack by emitting a new list.
GoRouter: the pragmatic wrapper
Writing a full delegate/parser pair is verbose. The go_router package provides a ready‑made RouterDelegate and RouteInformationParser plus:
- Declarative route definitions with path parameters (
/blog/:id). - Redirect guards for authentication or feature flags.
- Custom transition builders.
- Automatic handling of the browser’s back/forward buttons.
You still interact with the same Page concepts, but the boilerplate disappears.
Worked example: a tiny blog with a login guard
The following snippets assume Flutter 3.22+ and go_router: ^13.0. They run on Chrome, Android, and iOS.
1. Define the route data
enum AppRoute { home, blogList, blogDetail, login, admin }
class RouteConfig {
final AppRoute route;
final Map<String, String> params;
const RouteConfig(this.route, {this.params = const {}});
}
2. Configure GoRouter
final _router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (_, __) => const HomePage(),
),
GoRoute(
path: '/blog',
builder: (_, __) => const BlogListPage(),
),
GoRoute(
path: '/blog/:id',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return BlogDetailPage(articleId: id);
},
),
GoRoute(
path: '/login',
builder: (_, __) => const LoginPage(),
),
GoRoute(
path: '/admin',
builder: (_, __) => const AdminPage(),
),
],
redirect: (state) {
final loggedIn = AuthService.instance.isLoggedIn;
final goingToAdmin = state.matchedLocation == '/admin';
if (goingToAdmin && !loggedIn) {
return '/login?redirect=/admin';
}
return null; // no redirect
},
);
3. Wire the router into the app
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
title: 'Flutter Blog Demo',
);
}
}
4. Navigating imperatively (still allowed)
// From a button in BlogListPage
context.go('/blog/42'); // pushes detail, updates URL
// After successful login
final redirect = context.read<LoginViewModel>().redirectUrl ?? '/blog';
context.go(redirect);
Trade‑offs and limitations
- Learning curve: You must think in terms of page stacks and URL parsing rather than imperative calls. Teams used to
Navigator.pushoften need a short ramp‑up. - Platform deep‑link setup: Navigator 2.0 (and GoRouter) only react to URL changes inside the Flutter engine. On Android you still need an
<intent-filter>forhttps://yourdomain/*; on iOS you must declareCFBundleURLTypesinInfo.plist. The router does not generate these manifests. - State restoration: Because pages are rebuilt from the URL, any transient UI state (scroll position, text field input) must be stored in the page’s data model or a separate controller if you want it preserved across reloads.
How to verify it works
- Run the app on Chrome:
(Requires a Chrome installation and theflutter run -d chromeflutterSDK on PATH.) - Navigate to
http://localhost:PORT/blog/42(the port is printed by the command). The detail page should render article 42 and the address bar must show the same URL. - Press the browser back button. The router delegate pops the detail page; the list page reappears and the URL returns to
/blog. - While unauthenticated, open
/admin. The redirect guard fires, the URL becomes/login?redirect=/admin, and the login page appears.
If any step fails, inspect the console for GoRouter debug logs (enable with GoRouter.setDebugLogDiagnostics(true)) and verify that your RouteInformationParser (handled internally by GoRouter) receives the expected location.
Next steps
Adopt GoRouter for new features first; migrate existing imperative navigation incrementally by wrapping legacy screens in GoRoute entries. Keep the platform‑specific deep‑link manifests in sync with the route paths you declare. When you need custom transitions, supply a pageBuilder to the route definition instead of the default builder.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.