Flutter Navigation: Skalierbares Routing mit go_router

13 Min. Lesezeit9. Februar 2026Aktualisiert: 9. März 2026
Flutter go_routerFlutter navigationFlutter deep linkFlutter route guardFlutter nested routesgo_router redirectFlutter shell routeFlutter routing best practices

# 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:

  • **Deklarative Route-Definitionen** — alle Routen sind an einer Stelle sichtbar
  • **Eingebaute Redirect-Logik** — Auth-Guards, Onboarding-Checks, rollenbasierter Zugriff
  • **Deep-Link-Support direkt eingebaut** — Android App Links, iOS Universal Links und Web-URLs funktionieren ohne zusätzliche Konfiguration
  • **Shell Routes** — persistente Bottom-Navigation-Bars, Seitenleisten oder Tab-Layouts, ohne die Shell bei jeder Navigation neu aufzubauen
  • **Typsichere Path-Parameter und Query-Parameter**
  • **Einfache Testbarkeit** — die Redirect-Logik lässt sich per Unit-Test prüfen, ohne einen Widget-Tree hochzufahren
  • Installation

    go_router in die `pubspec.yaml` eintragen:

    dart
    dependencies:
      go_router: ^class="code-number">14.0.class="code-number">0

    Danach `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:

    dart
    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:

    dart
    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.

    dart
    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:

    dart
    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

    dart
    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.

    dart
    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:

    dart
    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.

    dart
    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:

    dart
    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:

    dart
    <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.de

    Auf dem Server eine `.well-known/apple-app-site-association`-Datei bereitstellen:

    dart
    {
      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

    dart
    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:

  • **go_router einrichten** — mit `MaterialApp.router` starten und Routen für ein Feature definieren
  • **`Navigator.push`-Aufrufe vorübergehend beibehalten** — go_router sitzt auf Navigator, daher funktionieren alte Aufrufe weiterhin
  • **Ein Feature nach dem anderen migrieren** — `Navigator.pushNamed`-Aufrufe durch `context.goNamed` ersetzen
  • **Auth-Guards** von einzelnen Screens in den Top-Level-`redirect` verschieben
  • **Das alte `onGenerateRoute` entfernen**, sobald alle Features migriert sind
  • 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:

    dart
    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:

    dart
    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:

    dart
    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:

    dart
    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

    Haben Sie ein Flutter-Projekt?

    Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

    Kontakt aufnehmen