Navigation & Deep Linking with GoRouter
Navigation & Deep Linking with GoRouter
GoRouter is the officially recommended declarative routing package for Flutter. It replaces the low-level Navigator API with a URL-based, declarative configuration that supports deep links, web URLs, nested navigation for bottom tabs, and route guards — all in one coherent model. In this lesson you will configure GoRouter from scratch, protect routes with an auth redirect, wire up nested navigation for a tabbed shell, and handle deep links arriving from external sources.
1. Setting Up GoRouter
Add the dependency and create a single router instance, typically in its own file so it can be imported anywhere without circular dependencies.
pubspec.yaml & router configuration
# pubspec.yaml
dependencies:
go_router: ^13.2.0
# ─────────────────────────────────────────────
# lib/router/app_router.dart
import 'package:go_router/go_router.dart';
import 'package:flutter/material.dart';
final GoRouter appRouter = GoRouter(
initialLocation: '/home',
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/home',
name: 'home',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'detail/:id', // becomes /home/detail/:id
name: 'detail',
builder: (context, state) {
final id = state.pathParameters['id']!;
return DetailScreen(itemId: id);
},
),
],
),
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfileScreen(),
),
],
);
Pass the router to MaterialApp.router instead of the regular MaterialApp:
main.dart — wiring GoRouter into the app
import 'package:flutter/material.dart';
import 'router/app_router.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'GoRouter Demo',
routerConfig: appRouter, // <— the single router instance
);
}
}
MaterialApp(home:) and MaterialApp(routes:). Once you switch to MaterialApp.router, all navigation must go through the GoRouter instance — mixing the two leads to conflicts.2. Named Routes & Typed Parameters
Using name: on every route lets you navigate without hard-coding URL strings, which prevents typos and makes refactoring safe.
context.goNamed('detail', pathParameters: {'id': '42'})— replaces the current stack entrycontext.pushNamed('detail', pathParameters: {'id': '42'})— pushes on top of the stackstate.pathParameters['id']— reads a URL segment declared as:idstate.uri.queryParameters['q']— reads a query string like?q=flutter
3. Route Guards — Protecting Auth-Required Routes
GoRouter's redirect callback runs before every navigation event. Returning a new path redirects the user; returning null allows the navigation to proceed. The cleanest pattern is to listen to an auth stream via refreshListenable so that changes to login state automatically re-evaluate all redirects.
Auth guard with refreshListenable
import 'package:flutter_riverpod/flutter_riverpod.dart';
// A ChangeNotifier that wraps your auth state
class AuthNotifier extends ChangeNotifier {
bool _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
void login() { _isLoggedIn = true; notifyListeners(); }
void logout() { _isLoggedIn = false; notifyListeners(); }
}
final authNotifier = AuthNotifier();
// ── router with global redirect ──────────────────────────────
final GoRouter appRouter = GoRouter(
initialLocation: '/home',
refreshListenable: authNotifier, // re-runs redirect on auth change
redirect: (context, state) {
final loggedIn = authNotifier.isLoggedIn;
final onLoginPage = state.matchedLocation == '/login';
if (!loggedIn && !onLoginPage) return '/login';
if (loggedIn && onLoginPage) return '/home';
return null; // allow navigation
},
routes: [ /* ... */ ],
);
refreshListenable accepts any Listenable, so you can pass a Riverpod ProviderListenable, a ValueNotifier, or merge multiple notifiers with Listenable.merge([a, b]).4. Nested Navigation with ShellRoute (Bottom Tabs)
ShellRoute wraps a group of routes inside a persistent shell widget — perfect for bottom navigation bars where tabs share a common scaffold but each tab maintains its own navigation stack.
ShellRoute for tabbed navigation
import 'package:go_router/go_router.dart';
final GoRouter appRouter = GoRouter(
initialLocation: '/feed',
routes: [
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/feed',
name: 'feed',
builder: (_, __) => const FeedScreen(),
routes: [
GoRoute(
path: 'post/:postId',
name: 'post',
builder: (context, state) => PostScreen(
postId: state.pathParameters['postId']!,
),
),
],
),
GoRoute(
path: '/search',
name: 'search',
builder: (_, __) => const SearchScreen(),
),
GoRoute(
path: '/profile',
name: 'profile',
builder: (_, __) => const ProfileScreen(),
),
],
),
],
);
// The shell widget that holds the BottomNavigationBar
class AppShell extends StatelessWidget {
final Widget child;
const AppShell({super.key, required this.child});
static const _tabs = ['/feed', '/search', '/profile'];
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
final index = _tabs.indexWhere((t) => location.startsWith(t));
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: index < 0 ? 0 : index,
onTap: (i) => context.go(_tabs[i]),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Feed'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
5. Handling Deep Links from External Sources
Deep links are URLs that open the app and navigate directly to a specific screen — e.g., a push notification that opens myapp://post/123, or a web link that launches the Flutter web build at https://myapp.com/post/123. GoRouter handles both automatically once you configure the URL scheme in your platform files.
- Android: add an
<intent-filter>with your scheme inAndroidManifest.xml - iOS: add a
CFBundleURLTypesentry (custom scheme) orAssociated Domains(universal links) inInfo.plist - Flutter Web: works out of the box — GoRouter reads the browser URL path automatically
/profile) and the user is not authenticated, your redirect callback must redirect to /login AND store the originally requested path so you can redirect back after login. Store it in the query parameter: redirect to /login?redirect=/profile, then read state.uri.queryParameters['redirect'] after successful login.Summary
GoRouter brings URL-first, declarative navigation to Flutter. The core concepts are: a single GoRouter instance with named GoRoutes, a global redirect callback powered by refreshListenable for auth guards, ShellRoute for persistent bottom-tab shells with nested stacks, and zero-configuration deep-link handling once platform URL schemes are registered. These building blocks are sufficient for any production-grade navigation architecture.