Flutter Navigation 2.0 and GoRouter in 2026: Deep Linking and Interview Questions

Master Flutter navigation with GoRouter 17.5: declarative routing, deep linking, ShellRoute, route guards, and common interview questions with practical examples.

Flutter navigation architecture with GoRouter deep linking flow diagram

Flutter navigation has evolved significantly since the introduction of Navigation 2.0. GoRouter 17.5, the official Flutter team package, now handles the complexity of the Router API while providing declarative routing, automatic deep linking, and nested navigation out of the box.

GoRouter is feature-complete

The Flutter team considers GoRouter feature-complete as of 2026. It supports path parameters, query parameters, redirects, ShellRoute for persistent UI, and StatefulShellRoute for preserving tab state. Raw Navigator 2.0 is rarely hand-rolled in production anymore.

Understanding Navigator 2.0 Architecture

Navigator 2.0 introduced a declarative, URL-driven approach to Flutter navigation. Instead of imperative push and pop calls, the app state determines what appears on the navigation stack. The architecture relies on three core classes:

  • Router: The top-level widget that coordinates navigation
  • RouteInformationParser: Translates URLs into app state
  • RouterDelegate: Builds the widget tree based on that state

When a deep link arrives from the OS or a URL changes in a web browser, the framework updates the state, and the UI reacts. The problem: writing a custom RouterDelegate and RouteInformationParser by hand requires significant boilerplate.

raw_navigator2_example.dartdart
// Raw Navigator 2.0 approach - verbose and rarely used directly
class AppRouterDelegate extends RouterDelegate<AppRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {
  
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  AppRoutePath? _currentPath;

  
  AppRoutePath? get currentConfiguration => _currentPath;

  
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        const MaterialPage(child: HomeScreen()),
        if (_currentPath?.isProductPage == true)
          MaterialPage(child: ProductScreen(id: _currentPath!.productId!)),
      ],
      onDidRemovePage: (page) {
        // Handle page removal
      },
    );
  }

  
  Future<void> setNewRoutePath(AppRoutePath path) async {
    _currentPath = path;
    notifyListeners();
  }
}

This verbosity is why GoRouter exists. It wraps Navigator 2.0 and handles the boilerplate.

Setting Up GoRouter 17.5

GoRouter 17.5 introduces route metadata support and regular expression constraints for path parameters. The minimum SDK requirement is Flutter 3.32 and Dart 3.8.

router_config.dartdart
import 'package:go_router/go_router.dart';

final goRouter = GoRouter(
  initialLocation: '/',
  debugLogDiagnostics: true, // Useful during development
  routes: [
    GoRoute(
      path: '/',
      name: 'home',
      builder: (context, state) => const HomeScreen(),
      routes: [
        GoRoute(
          path: 'products/:id', // Path parameter
          name: 'product',
          builder: (context, state) {
            final productId = state.pathParameters['id']!;
            return ProductScreen(productId: productId);
          },
        ),
        GoRoute(
          path: 'search',
          name: 'search',
          builder: (context, state) {
            // Query parameters: /search?q=flutter&category=books
            final query = state.uri.queryParameters['q'] ?? '';
            final category = state.uri.queryParameters['category'];
            return SearchScreen(query: query, category: category);
          },
        ),
      ],
    ),
  ],
);

The router integrates with MaterialApp.router:

main.dartdart
import 'package:flutter/material.dart';
import 'router_config.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: goRouter,
      title: 'GoRouter Demo',
    );
  }
}

Deep Linking Configuration for Android and iOS

Deep linking allows external URLs to open specific screens in the app. GoRouter handles the routing automatically once the platform configuration is in place.

Android requires an intent-filter in AndroidManifest.xml and a Digital Asset Links file hosted on the domain:

xml
<!-- android/app/src/main/AndroidManifest.xml -->
<activity android:name=".MainActivity">
  <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data
      android:scheme="https"
      android:host="example.com"
      android:pathPrefix="/products" />
  </intent-filter>
</activity>

The assetlinks.json file must be served at https://example.com/.well-known/assetlinks.json.

For iOS, the Associated Domains capability must be enabled in Xcode, and an apple-app-site-association file must be hosted:

https://example.com/.well-known/apple-app-site-associationjson
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.example.app",
        "paths": ["/products/*", "/search"]
      }
    ]
  }
}

GoRouter 17.5 fixed a critical bug where Android cold-start deep links with empty paths lost the scheme and authority. This means deep links now work reliably even when the app is not running.

Testing deep links

Test Android deep links with adb shell am start -a android.intent.action.VIEW -d "https://example.com/products/123". For iOS, use xcrun simctl openurl booted "https://example.com/products/123".

ShellRoute for Persistent Navigation UI

ShellRoute wraps child routes with a persistent UI element like a BottomNavigationBar or Drawer. The shell stays visible while navigating between its children.

shell_router_config.dartdart
final goRouter = GoRouter(
  initialLocation: '/home',
  routes: [
    ShellRoute(
      builder: (context, state, child) {
        return ScaffoldWithNavBar(child: child);
      },
      routes: [
        GoRoute(
          path: '/home',
          name: 'home',
          builder: (context, state) => const HomeTab(),
        ),
        GoRoute(
          path: '/explore',
          name: 'explore',
          builder: (context, state) => const ExploreTab(),
        ),
        GoRoute(
          path: '/profile',
          name: 'profile',
          builder: (context, state) => const ProfileTab(),
        ),
      ],
    ),
  ],
);

// scaffold_with_nav_bar.dart
class ScaffoldWithNavBar extends StatelessWidget {
  final Widget child;
  const ScaffoldWithNavBar({super.key, required this.child});

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _calculateSelectedIndex(context),
        onTap: (index) => _onItemTapped(index, context),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.explore), label: 'Explore'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }

  int _calculateSelectedIndex(BuildContext context) {
    final location = GoRouterState.of(context).uri.toString();
    if (location.startsWith('/explore')) return 1;
    if (location.startsWith('/profile')) return 2;
    return 0;
  }

  void _onItemTapped(int index, BuildContext context) {
    switch (index) {
      case 0: context.go('/home');
      case 1: context.go('/explore');
      case 2: context.go('/profile');
    }
  }
}

GoRouter 17.5 fixed an issue where iOS back gestures would pop the entire ShellRoute instead of just the active sub-route.

Ready to ace your Flutter interviews?

Practice with our interactive simulators, flashcards, and technical tests.

StatefulShellRoute for Preserving Tab State

When users switch tabs, ShellRoute rebuilds the child. StatefulShellRoute preserves the navigation stack of each branch:

stateful_shell_config.dartdart
final goRouter = GoRouter(
  initialLocation: '/home',
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return ScaffoldWithNavBar(navigationShell: navigationShell);
      },
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/home',
              builder: (context, state) => const HomeTab(),
              routes: [
                GoRoute(
                  path: 'details/:id',
                  builder: (context, state) => DetailsScreen(
                    id: state.pathParameters['id']!,
                  ),
                ),
              ],
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/explore',
              builder: (context, state) => const ExploreTab(),
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/profile',
              builder: (context, state) => const ProfileTab(),
            ),
          ],
        ),
      ],
    ),
  ],
);

With this setup, if a user navigates to /home/details/42, switches to the Explore tab, then returns to Home, they will still be on the details screen.

Route Guards with Redirect

Redirects handle authentication checks and conditional navigation. The redirect callback runs before each navigation:

authenticated_router.dartdart
final goRouter = GoRouter(
  initialLocation: '/',
  redirect: (context, state) {
    final isLoggedIn = AuthService.instance.isLoggedIn;
    final isLoggingIn = state.matchedLocation == '/login';

    // Redirect to login if not authenticated
    if (!isLoggedIn && !isLoggingIn) {
      return '/login?redirect=${state.uri}';
    }

    // Redirect away from login if already authenticated
    if (isLoggedIn && isLoggingIn) {
      final redirect = state.uri.queryParameters['redirect'];
      return redirect ?? '/';
    }

    return null; // No redirect
  },
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => const LoginScreen(),
    ),
    GoRoute(
      path: '/admin',
      redirect: (context, state) {
        // Route-level redirect for admin-only access
        if (!AuthService.instance.isAdmin) {
          return '/';
        }
        return null;
      },
      builder: (context, state) => const AdminScreen(),
    ),
  ],
);

GoRouter 17.5 added support for using context extension methods like context.namedLocation() within redirect callbacks.

Common Flutter Navigation Interview Questions

Interviewers frequently test navigation knowledge because it touches architecture, state management, and platform integration. Here are questions that distinguish senior candidates:

Q: What problem does Navigator 2.0 solve that Navigator 1.0 does not?

Navigator 1.0 uses imperative navigation: Navigator.push() and Navigator.pop(). The app cannot represent the navigation state as a URL, making deep linking and web support difficult. Navigator 2.0 is declarative: the app state determines the navigation stack, URLs are first-class citizens, and deep links work automatically.

Q: When would you use context.go() vs context.push()?

context.go('/path') replaces the current navigation stack up to the matched route. context.push('/path') adds a new route on top of the existing stack. Use go() for top-level navigation (switching tabs, going home) and push() for drilling into details while preserving back navigation.

Q: How do you preserve state across tab switches?

Use StatefulShellRoute.indexedStack(). It maintains separate navigation stacks for each branch and preserves them when switching tabs. Without it, switching tabs rebuilds the entire sub-tree.

Q: How do you pass complex objects between routes?

Three approaches exist. First, pass an ID in the path parameter and fetch the object on the destination screen. Second, use GoRouter's extra parameter to pass the object directly. Third, store the object in a state management solution (Riverpod, Bloc) and access it from the destination. The first approach is best for deep linking because the URL remains shareable.

dart
// Passing via extra (not deep-linkable)
context.push('/product', extra: productObject);

// Receiving via extra
GoRoute(
  path: '/product',
  builder: (context, state) {
    final product = state.extra as Product?;
    return ProductScreen(product: product);
  },
),

Q: What is the onEnter callback in GoRouter 17.5?

The onEnter callback executes when entering a route and provides access to both the current and next route states. It allows running logic before the route fully loads, useful for analytics or conditional pre-loading.

Type-Safe Routes with code generation

GoRouter supports type-safe routing through code generation. Define route data classes and let build_runner generate the navigation code:

routes.dartdart
import 'package:go_router/go_router.dart';

part 'routes.g.dart';

<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
  const HomeRoute();

  
  Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}

<ProductRoute>(path: '/product/:id')
class ProductRoute extends GoRouteData {
  final String id;
  const ProductRoute({required this.id});

  
  Widget build(BuildContext context, GoRouterState state) =>
      ProductScreen(productId: id);
}

After running dart run build_runner build, navigate with compile-time safety:

dart
// Type-safe navigation
ProductRoute(id: '123').go(context);

This eliminates typos in route paths and ensures parameters are always provided.

Mastering Flutter Navigation for Production Apps

  • GoRouter 17.5 is the recommended navigation solution for Flutter apps requiring deep linking, nested navigation, or web support
  • Use ShellRoute for persistent bottom navigation bars; use StatefulShellRoute to preserve each tab's navigation stack
  • Configure platform-specific deep linking (Android App Links, iOS Universal Links) separately from GoRouter, which handles the routing logic
  • Implement authentication guards with the redirect callback at the router level or individual route level
  • For interview scenarios, focus on explaining the declarative model: app state drives the UI, URLs are derived from state, and GoRouter handles the translation
  • Type-safe routes with code generation eliminate runtime errors from mistyped paths
  • Check the Flutter navigation documentation and GoRouter changelog for version-specific features

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Daily challenge

Can you spot the bug in Flutter?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on August 23, 2026

Tags

#flutter
#navigation
#gorouter
#deep-linking
#mobile

Share

Related articles