Skip to content

Declarative Routing

Imperative navigation works fine until you need a deep link. When a user opens myapp.com/orders/42 from a cold start, there is no sequence of push calls that happened — you must reconstruct the whole stack (Home → Orders → Order 42) from a URL. Doing that by hand with Navigator.push is painful and error-prone.

The fix is to make the route stack a declarative function of state: given the current URL or app state, compute the stack. This is Navigator 2.0. Because the raw API is verbose, almost everyone uses go_router, the official package built on top of it.

flowchart LR
  state["app state / URL
/orders/42"] --> router["go_router
(route table)"]
  router --> stack["computed stack
Home then Orders then Order 42"]
Declarative routing derives the stack from state

You declare the routes once, as data. go_router maps a path to a builder, supports typed path parameters, and keeps the browser URL in sync automatically on web.

final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(
path: '/orders/:id', // typed path parameter
builder: (context, state) {
final id = state.pathParameters['id']!;
return OrderScreen(orderId: id);
},
),
],
);
// Navigate by path — no manual stack juggling:
context.go('/orders/42');

context.go('/orders/42') sets the location; the router recomputes the stack. On web, the URL bar updates to match, and pasting that URL reconstructs the same screen — deep linking for free.

A shell route wraps a set of routes in shared UI — the classic bottom-navigation app where the tab bar persists while the inner content changes. The shell stays mounted; only the child route swaps.

ShellRoute(
builder: (context, state, child) => ScaffoldWithNavBar(child: child),
routes: [
GoRoute(path: '/feed', builder: (context, state) => const FeedScreen()),
GoRoute(path: '/profile', builder: (context, state) => const ProfileScreen()),
],
);

A redirect runs before a route builds and can send the user elsewhere — the idiomatic place for authentication guards. Return a new path to redirect, or null to proceed.

GoRouter(
redirect: (context, state) {
final loggedIn = auth.isLoggedIn;
final goingToLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingToLogin) return '/login'; // guard
if (loggedIn && goingToLogin) return '/'; // already in
return null; // no redirect
},
routes: [ /* ... */ ],
);
Why does imperative push/pop navigation struggle with deep links?
What is the core idea of declarative routing?
What is a shell route used for?
Where do you idiomatically put an authentication guard in go_router?