Have you ever opened an app and landed on the wrong screen? Or worse, a user arrives from a link and sees the home page instead of the product they searched for on Google? That's what happens when navigation is poorly managed. We, at Meteora Web, have seen projects where route handling was a mess of ad-hoc pushes and pops. Here's how to bring order.
Why is Flutter navigation different from other technologies?
Flutter doesn't use the native view system of iOS or Android. It has its own graphics engine (Skia / Impeller) and its own routing stack. This means the navigation logic is entirely under your control, but you also need to understand it deeply to avoid subtle bugs.
The Navigator widget works like a stack: each screen is a Route that gets pushed or popped. Seems simple, but when you need to handle authentication, deep links, tab bars, and data passing, things get complex.
How do you define routes in Flutter?
There are two main approaches:
Declarative Routes (Navigator 2.0)
With Router and RouteInformationParser you manage navigation as app state. Ideal for complex apps and deep links. We use the go_router package to simplify.
Sponsored Protocol
Imperative Routes (Navigator 1.0)
The classic Navigator.push and pop. Fine for small apps, but doesn't scale.
// Navigator 1.0
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ProductDetail(id: 42)),
);The problem? If the user receives a push notification with a different product ID, you have to manually handle the route. With Navigator 2.0 and go_router, the path is declared centrally.
How to handle deep links with Flutter and GoRouter?
Deep links allow a user to arrive directly at a specific app screen from an external link (e.g., email, Google ad). In Flutter, you must configure two things: the operating system (iOS Universal Links, Android App Links) and the Dart-side router.
With go_router, configuration is straightforward:
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomeScreen(),
),
GoRoute(
path: '/product/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetail(id: int.parse(id));
},
routes: [
GoRoute(
path: 'reviews',
builder: (context, state) => ProductReviews(id: id),
),
],
),
],
);Then in main.dart, wrap the app with MaterialApp.router:
Sponsored Protocol
MaterialApp.router(
routerConfig: router,
// ...
);For native deep links, you also need to add the apple-app-site-association file (iOS) and assetlinks.json (Android). At Meteora Web, we automated this step with a script that generates and uploads the files to the server.
Data passing between screens: what to avoid?
Common mistake: passing complex objects as Map or serializing everything to JSON. In Flutter, the cleanest way is to use route constructors. With go_router, you can pass extra:
context.go('/product/42', extra: {'utm': 'email', 'campaign': 'spring'});Then in the builder read it with state.extra. Simple and traceable.
Conditional navigation: login, onboarding, and user state
Many apps need to redirect the user based on authentication state. With go_router you can use redirect:
Sponsored Protocol
final router = GoRouter(
redirect: (context, state) {
final isLoggedIn = AuthService.isLoggedIn;
final isOnLoginPage = state.matchedLocation == '/login';
if (!isLoggedIn && !isOnLoginPage) return '/login';
if (isLoggedIn && isOnLoginPage) return '/';
return null;
},
// ...
);Warning: avoid infinite redirect loops. return null means no redirect.
How to test navigation in Flutter?
We write widget tests for every critical route. With go_router you can create a test router and inject fake dependencies:
testWidgets('Deep link opens product screen', (tester) async {
await tester.pumpWidget(
MaterialApp.router(
routerConfig: GoRouter(
initialLocation: '/product/123',
routes: [/* ... */],
),
),
);
expect(find.text('Product Detail 123'), findsOneWidget);
});Don't forget integration tests for real deep links: simulate an incoming link from the operating system.
Common navigation mistakes in Flutter and how to avoid them
Don't use Navigator.push inside an async callback without the right context
The context might be unmounted. Use context.mounted (Flutter 3.7+) or a global navigatorKey.
Sponsored Protocol
Ignoring PopScope (or WillPopScope)
If the user presses back during a save, you can show a dialog. With PopScope you block the pop until safe.
PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
// Show confirmation dialog
showDialog(...);
}
},
child: Scaffold(...),
);What to do now
Here are three concrete actions to improve navigation in your Flutter app:
- Replace
Navigator.pushwith go_router if you have more than 5 screens. Run:flutter pub add go_router. - Set up deep links for iOS and Android following the official Flutter guide. Create the JSON files and verify them with Google's testing tool.
- Add an authentication redirect using the
redirectproperty of GoRouter. Test it with a non-logged-in user.
Want to dive deeper? Read our Flutter Pillar for a complete view of cross-platform development.