Flutter Navigation: Skalierbares Routing mit go_router
# Flutter Navigation mit go_router
Sobald eine Flutter-App über eine Handvoll Screens hinauswächst, wird Navigation schnell zu einem der schwierigsten Themen. Deep Links brechen, Auth-Guards verteilen sich über diverse Widgets, und Route-Definitionen werden an mehreren Stellen dupliziert. Diesen Kreislauf habe ich in mehreren Projekten erlebt, und go_router ist das Werkzeug, das endlich Ordnung in meine Routing-Schicht gebracht hat.
In diesem Artikel zeige ich ein vollständiges go_router-Setup für eine mittelgroße App, beschreibe die Migration von den älteren Navigator-APIs und teile die Fehler, die ich mittlerweile zu vermeiden gelernt habe.
Warum go_router?
Flutter liefert Navigator 1.0 (imperatives push/pop) und Navigator 2.0 (deklarativ, aber sehr wortreich) mit. go_router sitzt auf Navigator 2.0 und bietet:
Installation
go_router in die `pubspec.yaml` eintragen:
dependencies:
go_router: ^class="code-number">14.0.class="code-number">0Danach `flutter pub get` ausführen.
Grundlegende Route-Konfiguration
Das einfachste mögliche Setup. Eine `GoRouter`-Instanz definiert die Routen und wird an `MaterialApp.router` übergeben:
import class="code-string">'package:go_router/go_router.dart';
final GoRouter router = GoRouter(
initialLocation: class="code-string">'/',
routes: [
GoRoute(
path: class="code-string">'/',
name: class="code-string">'home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: class="code-string">'/settings',
name: class="code-string">'settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: class="code-string">'/profile/:userId',
name: class="code-string">'profile',
builder: (context, state) {
final userId = state.pathParameters[class="code-string">'userId']!;
return ProfileScreen(userId: userId);
},
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
);
}
}Die Navigation ist unkompliziert:
class=class="code-string">"code-comment">// Per Pfad
context.go(class="code-string">'/profile/class="code-number">42');
class=class="code-string">"code-comment">// Per Name (mein bevorzugter Ansatz)
context.goNamed(class="code-string">'profile', pathParameters: {class="code-string">'userId': class="code-string">'class="code-number">42'});
class=class="code-string">"code-comment">// Auf den Stack pushen statt zu ersetzen
context.push(class="code-string">'/settings');Vollständiges Setup für eine mittelgroße App
In meinen Apps strukturiere ich Routen nach Features. Jedes Feature-Modul exportiert seine eigene Liste von `RouteBase`-Objekten, und der Top-Level-Router fügt sie zusammen. So bleiben die Dateien klein, und es ist sofort klar, welches Team welche Route verantwortet.
class=class="code-string">"code-comment">// lib/routing/app_router.dart
import class="code-string">'package:go_router/go_router.dart';
import class="code-string">'package:flutter_riverpod/flutter_riverpod.dart';
import class="code-string">'route_names.dart';
import class="code-string">'auth_routes.dart';
import class="code-string">'home_routes.dart';
import class="code-string">'profile_routes.dart';
import class="code-string">'admin_routes.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
return GoRouter(
initialLocation: class="code-string">'/',
debugLogDiagnostics: true,
refreshListenable: authState,
redirect: (context, state) {
final isLoggedIn = authState.isAuthenticated;
final isOnLoginPage = state.matchedLocation == class="code-string">'/login';
final isOnOnboarding = state.matchedLocation == class="code-string">'/onboarding';
class=class="code-string">"code-comment">// Nicht eingeloggt — zur Login-Seite weiterleiten
if (!isLoggedIn && !isOnLoginPage) return class="code-string">'/login';
class=class="code-string">"code-comment">// Eingeloggt, aber auf der Login-Seite — zur Startseite weiterleiten
if (isLoggedIn && isOnLoginPage) return class="code-string">'/';
class=class="code-string">"code-comment">// Eingeloggt, aber Onboarding nicht abgeschlossen
if (isLoggedIn && !authState.onboardingComplete && !isOnOnboarding) {
return class="code-string">'/onboarding';
}
return null; class=class="code-string">"code-comment">// keine Weiterleitung
},
routes: [
...authRoutes,
ShellRoute(
builder: (context, state, child) {
return AppShell(child: child);
},
routes: [
...homeRoutes,
...profileRoutes,
...adminRoutes,
],
),
],
errorBuilder: (context, state) => NotFoundScreen(
error: state.error,
),
);
});Route-Namen als Konstanten
Pfad-Strings überall hardzucoden ist der schnellste Weg zu Tippfehlern. Ich halte alle Route-Namen in einer einzigen Datei:
class=class="code-string">"code-comment">// lib/routing/route_names.dart
abstract class RouteNames {
static const home = class="code-string">'home';
static const login = class="code-string">'login';
static const onboarding = class="code-string">'onboarding';
static const profile = class="code-string">'profile';
static const profileEdit = class="code-string">'profile-edit';
static const settings = class="code-string">'settings';
static const adminDashboard = class="code-string">'admin-dashboard';
static const adminUsers = class="code-string">'admin-users';
static const articleDetail = class="code-string">'article-detail';
}Feature-basierte Route-Dateien
class=class="code-string">"code-comment">// lib/routing/home_routes.dart
import class="code-string">'package:go_router/go_router.dart';
import class="code-string">'route_names.dart';
final homeRoutes = <RouteBase>[
GoRoute(
path: class="code-string">'/',
name: RouteNames.home,
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: class="code-string">'article/:articleId',
name: RouteNames.articleDetail,
builder: (context, state) {
final id = state.pathParameters[class="code-string">'articleId']!;
return ArticleDetailScreen(articleId: id);
},
),
],
),
GoRoute(
path: class="code-string">'/settings',
name: RouteNames.settings,
builder: (context, state) => const SettingsScreen(),
),
];Verschachtelte Routen und Shell Routes
Shell Routes gehören zu den mächtigsten Features von go_router. Sie erlauben es, ein persistentes UI-Element — etwa eine Bottom-Navigation-Bar — beizubehalten, während nur der Inhaltsbereich ausgetauscht wird.
ShellRoute(
builder: (context, state, child) {
return ScaffoldWithBottomNav(child: child);
},
routes: [
GoRoute(
path: class="code-string">'/feed',
name: class="code-string">'feed',
builder: (context, state) => const FeedScreen(),
),
GoRoute(
path: class="code-string">'/search',
name: class="code-string">'search',
builder: (context, state) => const SearchScreen(),
),
GoRoute(
path: class="code-string">'/profile',
name: class="code-string">'profile',
builder: (context, state) => const ProfileScreen(),
),
],
)Das `ScaffoldWithBottomNav`-Widget empfängt das `child` und platziert es im Body. Die Bottom-Navigation-Bar bleibt gemountet und behält ihren Zustand bei — nur der Inhalt wechselt.
StatefulShellRoute für unabhängige Navigation-Stacks
Wenn jeder Tab seinen eigenen Back-Stack benötigt (denkt an Instagram-artige Navigation), kommt `StatefulShellRoute` zum Einsatz:
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return MainShell(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: class="code-string">'/feed',
builder: (context, state) => const FeedScreen(),
routes: [
GoRoute(
path: class="code-string">'detail/:postId',
builder: (context, state) => PostDetailScreen(
postId: state.pathParameters[class="code-string">'postId']!,
),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: class="code-string">'/profile',
builder: (context, state) => const ProfileScreen(),
),
],
),
],
)Jeder Branch bewahrt seinen eigenen Navigation-Stack. Wenn ein Nutzer den Tab wechselt und zurückkehrt, bleibt die vorherige Position erhalten.
Redirect Guards
Redirects sind der sauberste Weg, Auth- und Berechtigungsprüfungen umzusetzen. Nach meiner Erfahrung ist es deutlich besser, die gesamte Redirect-Logik in einer einzigen Top-Level-Funktion zu bündeln, anstatt Guards auf einzelne Routen zu verteilen.
redirect: (context, state) {
final auth = ref.read(authProvider);
final location = state.matchedLocation;
class=class="code-string">"code-comment">// Öffentliche Routen, die nie weiterleiten
const publicRoutes = [class="code-string">'/login', class="code-string">'/register', class="code-string">'/forgot-password'];
if (publicRoutes.contains(location)) {
return auth.isLoggedIn ? class="code-string">'/' : null;
}
class=class="code-string">"code-comment">// Alles andere erfordert Authentifizierung
if (!auth.isLoggedIn) {
return class="code-string">'/login?redirect=${Uri.encodeComponent(location)}';
}
class=class="code-string">"code-comment">// Admin-Routen erfordern Admin-Rolle
if (location.startsWith(class="code-string">'/admin') && !auth.isAdmin) {
return class="code-string">'/';
}
return null;
},Beachtet den `redirect`-Query-Parameter. Nach dem Login kann man ihn auslesen und den Nutzer dorthin zurückschicken, wo er ursprünglich hin wollte:
GoRoute(
path: class="code-string">'/login',
builder: (context, state) {
final redirectTo = state.uri.queryParameters[class="code-string">'redirect'] ?? class="code-string">'/';
return LoginScreen(redirectAfterLogin: redirectTo);
},
),Deep Linking einrichten
go_router übernimmt Deep Links auf der Routing-Seite automatisch, aber die Plattform-Konfiguration muss manuell erfolgen.
Android — App Links
Einen Intent-Filter in `android/app/src/main/AndroidManifest.xml` einfügen:
<activity ...>
<intent-filter android:autoVerify=class="code-string">"true">
<action android:name=class="code-string">"android.intent.action.VIEW" />
<category android:name=class="code-string">"android.intent.category.DEFAULT" />
<category android:name=class="code-string">"android.intent.category.BROWSABLE" />
<data
android:scheme=class="code-string">"https"
android:host=class="code-string">"meineapp.de"
android:pathPrefix=class="code-string">"/article" />
</intent-filter>
</activity>Auf der eigenen Domain eine `.well-known/assetlinks.json`-Datei bereitstellen, um die Inhaberschaft zu verifizieren.
iOS — Universal Links
In Xcode eine Associated-Domains-Berechtigung hinzufügen:
applinks:meineapp.deAuf dem Server eine `.well-known/apple-app-site-association`-Datei bereitstellen:
{
class="code-string">"applinks": {
class="code-string">"apps": [],
class="code-string">"details": [
{
class="code-string">"appID": class="code-string">"TEAMID.com.meinefirma.meineapp",
class="code-string">"paths": [class="code-string">"/article/*", class="code-string">"/profile/*"]
}
]
}
}Deep Links lokal testen
class=class="code-string">"code-comment">// Android
adb shell am start -a android.intent.action.VIEW \
-d class="code-string">"https:class="code-commentclass="code-string">">//meineapp.de/article/class="code-number">42" com.meinefirma.meineapp
class=class="code-string">"code-comment">// iOS
xcrun simctl openurl booted class="code-string">"https:class="code-commentclass="code-string">">//meineapp.de/article/class="code-number">42"go_router parst die eingehende URL und gleicht sie mit den definierten Routen ab. Wenn `/article/:articleId` im Route-Baum existiert, landet der Nutzer direkt auf `ArticleDetailScreen(articleId: '42')`.
Migration von Navigator 1.0 / 2.0
Von Navigator 1.0 (Imperativ)
Wenn die Codebasis `Navigator.push`, `Navigator.pushNamed` und `Navigator.pop` verwendet, ist die Migration unkompliziert:
| Navigator 1.0 | go_router |
|---|---|
| `Navigator.pushNamed(context, '/profile')` | `context.go('/profile')` oder `context.push('/profile')` |
| `Navigator.pop(context)` | `context.pop()` |
| `Navigator.pushReplacementNamed(context, '/home')` | `context.go('/home')` |
| `Navigator.pushAndRemoveUntil(...)` | `context.go('/home')` (leert den Stack) |
| `onGenerateRoute` | `GoRouter(routes: [...])` |
Der zentrale Unterschied: `context.go()` ersetzt den gesamten Navigation-Stack, um zum Zielpfad zu gelangen. `context.push()` legt darauf, ähnlich wie das alte `Navigator.push`.
Von Navigator 2.0 (RouterDelegate + RouteInformationParser)
Wer bereits ein eigenes Router-Setup aufgebaut hat, ersetzt mit go_router sowohl den `RouterDelegate` als auch den `RouteInformationParser`. Diese Klassen können vollständig gelöscht werden — die Route-Matching-Logik wandert in `GoRouter(routes: [...])`.
Migrationsstrategie
Ich empfehle, Feature für Feature zu migrieren statt alles auf einmal:
Häufige Routing-Fehler
1. `go()` verwenden, wenn `push()` gemeint ist
`context.go('/detail/42')` ersetzt den Stack. Wenn der Nutzer zurück drückt, kehrt er nicht zum vorherigen Screen zurück — er landet bei der übergeordneten Route. Für normales Zurück-Verhalten `context.push('/detail/42')` verwenden.
2. refreshListenable vergessen
Wenn die Redirect-Logik vom Auth-Zustand abhängt, muss go_router mitbekommen, dass sich dieser Zustand geändert hat:
GoRouter(
refreshListenable: authStateNotifier, class=class="code-string">"code-comment">// muss Listenable implementieren
redirect: (context, state) { ... },
)Ohne das bleibt ein ausgeloggter Nutzer auf der geschützten Seite, bis er manuell navigiert.
3. Route-Pfade duplizieren
Denselben Pfad an zwei verschiedenen Stellen zu definieren führt zu unvorhersehbarem Matching. In meiner Route-Struktur wird jeder Pfad genau einmal definiert, und ich überprüfe das mit einem simplen Test:
test(class="code-string">'keine doppelten Route-Pfade', () {
final paths = <String>[];
void collectPaths(List<RouteBase> routes, String prefix) {
for (final route in routes) {
if (route is GoRoute) {
final fullPath = class="code-string">'$prefix/${route.path}'.replaceAll(class="code-string">'class=class="code-string">"code-comment">//', class="code-string">'/');
expect(paths.contains(fullPath), isFalse,
reason: class="code-string">'Doppelter Pfad: $fullPath');
paths.add(fullPath);
}
if (route is GoRoute) collectPaths(route.routes, route.path);
if (route is ShellRoute) collectPaths(route.routes, prefix);
}
}
collectPaths(router.configuration.routes, class="code-string">'');
});4. Unnötig tiefe Verschachtelung
Jede verschachtelte Route verlängert den URL-Pfad. `/home/feed/post/42/comments/5/reply` ist ein Zeichen dafür, dass die Hierarchie flacher sein sollte. Ich versuche, die Verschachtelung auf maximal zwei Ebenen zu begrenzen.
5. Keine Fehler-Route definieren
Wenn ein Nutzer eine URL aufruft, die zu keiner Route passt, zeigt go_router eine Standard-Fehlerseite. Immer eine eigene bereitstellen:
GoRouter(
errorBuilder: (context, state) => const NotFoundScreen(),
)6. Komplexe Objekte über extra übergeben
`state.extra` erlaubt es, beliebige Objekte bei der Navigation mitzugeben, bricht aber Deep Links (das Objekt lässt sich nicht in eine URL serialisieren). Stattdessen IDs als Path- oder Query-Parameter übergeben und das vollständige Objekt auf dem Ziel-Screen laden.
Routen testen
go_router macht das Testen von Routen deutlich einfacher als rohes Navigator 2.0:
test(class="code-string">'leitet nicht authentifizierte Nutzer zur Login-Seite weiter', () {
final router = GoRouter(
initialLocation: class="code-string">'/profile',
redirect: (context, state) {
if (!isLoggedIn) return class="code-string">'/login';
return null;
},
routes: [
GoRoute(path: class="code-string">'/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: class="code-string">'/profile', builder: (_, __) => const ProfileScreen()),
],
);
class=class="code-string">"code-comment">// Nach der Initialisierung sollte der Router weitergeleitet haben
expect(router.location, class="code-string">'/login');
});Fazit
go_router ist mittlerweile meine Standard-Navigationslösung für jedes Flutter-Projekt, das über einen einfachen Prototyp hinausgeht. Die Kombination aus deklarativen Routen, zentralen Redirects, Shell Routes und Deep-Link-Support deckt die allermeisten realen Navigationsanforderungen ab. Strukturiert eure Routen nach Features, haltet die Redirect-Logik an einer Stelle, bevorzugt benannte Routen gegenüber rohen Pfaden — und ihr spart euch Stunden an Debugging.
Wenn ihr eine Migration von Navigator 1.0 oder 2.0 plant, helfe ich gerne bei der Erstellung eines schrittweisen Migrationsplans, der auf die Architektur eurer App zugeschnitten ist.
Verwandte Artikel
Clean Architecture in Flutter: Skalierbare Anwendungen entwickeln
Lernen Sie, Clean Architecture in Flutter praxisnah umzusetzen. Ein Leitfaden für Schichten, Dependency Management und testbaren Code.
Flutter Form Validation: Nutzbare und sichere Formulare
Lernen Sie praxistaugliche Muster für Validierung, Fehlermeldungen und bessere Formular-UX in Flutter.
Flutter Localization (i18n): Mehrsprachige Apps entwickeln
Setzen Sie skalierbare Lokalisierung in Flutter um: ARB-Dateien, Locale-Management und Übersetzungs-Workflow.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen