Skip to content
Tolinku
Tolinku
Sign In Start Free
Engineering · · 5 min read

Flutter go_router Deep Links: Routing Guide

By Tolinku Staff
|
Tolinku webhooks integrations dashboard screenshot for engineering blog posts

go_router is the recommended routing package for Flutter apps. It handles deep linking natively: define your routes with URL patterns, and go_router automatically matches incoming deep links to the correct screen. This guide covers the routing patterns you need for production deep linking.

For the platform-level configuration (Associated Domains, intent filters), see Flutter Deep Linking: Complete Setup Tutorial. For a cross-platform overview, see Cross-Platform Deep Linking Guide.

Basic Route Setup

Install go_router

# pubspec.yaml
dependencies:
  go_router: ^14.0.0

Define Routes

final GoRouter router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/product/:id',
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return ProductScreen(id: id);
      },
    ),
    GoRoute(
      path: '/category/:slug',
      builder: (context, state) {
        final slug = state.pathParameters['slug']!;
        return CategoryScreen(slug: slug);
      },
    ),
    GoRoute(
      path: '/invite/:code',
      builder: (context, state) {
        final code = state.pathParameters['code']!;
        return InviteScreen(code: code);
      },
    ),
  ],
  errorBuilder: (context, state) => const NotFoundScreen(),
);

Use with MaterialApp

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
    );
  }
}

When the OS delivers a deep link like https://go.yourapp.com/product/456, go_router strips the scheme and host, matches /product/456 against your route patterns, and renders ProductScreen(id: "456").

Nested Routes

ShellRoute for Persistent UI

Most apps have a bottom navigation bar that persists across screens. Use ShellRoute to keep the scaffold while deep links navigate the inner content:

final router = GoRouter(
  routes: [
    ShellRoute(
      builder: (context, state, child) {
        return ScaffoldWithBottomNav(child: child);
      },
      routes: [
        GoRoute(
          path: '/',
          builder: (context, state) => const HomeScreen(),
          routes: [
            GoRoute(
              path: 'product/:id',
              builder: (context, state) {
                final id = state.pathParameters['id']!;
                return ProductScreen(id: id);
              },
            ),
          ],
        ),
        GoRoute(
          path: '/search',
          builder: (context, state) => const SearchScreen(),
          routes: [
            GoRoute(
              path: 'results',
              builder: (context, state) {
                final query = state.uri.queryParameters['q'] ?? '';
                return SearchResultsScreen(query: query);
              },
            ),
          ],
        ),
        GoRoute(
          path: '/profile',
          builder: (context, state) => const ProfileScreen(),
        ),
      ],
    ),
  ],
);

Deep link to /product/123 shows ProductScreen inside the ScaffoldWithBottomNav, with the Home tab selected. Deep link to /search/results?q=shoes shows SearchResultsScreen with the Search tab selected.

StatefulShellRoute for Tab State Preservation

If you want each tab to maintain its own navigation state (so switching tabs doesn't reset the stack):

final router = GoRouter(
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return ScaffoldWithBottomNav(navigationShell: navigationShell);
      },
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/',
              builder: (context, state) => const HomeScreen(),
              routes: [
                GoRoute(
                  path: 'product/:id',
                  builder: (context, state) {
                    return ProductScreen(id: state.pathParameters['id']!);
                  },
                ),
              ],
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/search',
              builder: (context, state) => const SearchScreen(),
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/profile',
              builder: (context, state) => const ProfileScreen(),
            ),
          ],
        ),
      ],
    ),
  ],
);

Query Parameters

Access query parameters through state.uri.queryParameters:

GoRoute(
  path: '/search',
  builder: (context, state) {
    final query = state.uri.queryParameters['q'] ?? '';
    final sort = state.uri.queryParameters['sort'] ?? 'relevance';
    final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1;
    return SearchScreen(query: query, sort: sort, page: page);
  },
),

URL: https://go.yourapp.com/search?q=shoes&sort=price&page=2

Authentication Guards with Redirect

Deep links often target screens that require authentication. go_router's redirect function handles this:

final router = GoRouter(
  redirect: (context, state) {
    final isLoggedIn = authService.isLoggedIn;
    final isLoginRoute = state.matchedLocation == '/login';

    // If not logged in and not already on login, redirect to login
    if (isLoggedIn == false && isLoginRoute == false) {
      // Save the intended destination for after login
      return '/login?redirect=${Uri.encodeComponent(state.uri.toString())}';
    }

    // If logged in and on login page, redirect to home
    if (isLoggedIn && isLoginRoute) {
      return '/';
    }

    return null; // No redirect
  },
  routes: [
    GoRoute(
      path: '/login',
      builder: (context, state) {
        final redirect = state.uri.queryParameters['redirect'];
        return LoginScreen(redirectAfterLogin: redirect);
      },
    ),
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/product/:id',
      builder: (context, state) {
        return ProductScreen(id: state.pathParameters['id']!);
      },
    ),
  ],
);

After login, navigate to the saved redirect URL:

class LoginScreen extends StatelessWidget {
  final String? redirectAfterLogin;

  const LoginScreen({this.redirectAfterLogin});

  void onLoginSuccess(BuildContext context) {
    if (redirectAfterLogin != null) {
      context.go(Uri.decodeComponent(redirectAfterLogin!));
    } else {
      context.go('/');
    }
  }
}

This way, https://go.yourapp.com/product/123 for an unauthenticated user shows the login screen, and after login, automatically navigates to the product.

Route-Level Redirects

For redirects that apply to specific routes (not globally):

GoRoute(
  path: '/admin',
  redirect: (context, state) {
    if (authService.isAdmin == false) {
      return '/'; // Non-admins redirected to home
    }
    return null;
  },
  builder: (context, state) => const AdminScreen(),
),

Handling Legacy URLs

If you're migrating from another platform, your old links may use different URL patterns. Handle legacy paths with redirects:

final router = GoRouter(
  redirect: (context, state) {
    final path = state.matchedLocation;

    // Legacy: /item/123 → /product/123
    if (path.startsWith('/item/')) {
      return path.replaceFirst('/item/', '/product/');
    }

    // Legacy: /p?id=123 → /product/123
    if (path == '/p') {
      final id = state.uri.queryParameters['id'];
      if (id != null) return '/product/$id';
    }

    return null;
  },
  routes: [
    // Current routes...
  ],
);

TypeSafe Routes with Extra Data

go_router supports passing typed data alongside URL parameters:

GoRoute(
  path: '/product/:id',
  builder: (context, state) {
    // URL parameter (always a string)
    final id = state.pathParameters['id']!;

    // Extra data passed programmatically (not from URL)
    final product = state.extra as Product?;

    return ProductScreen(id: id, preloadedProduct: product);
  },
),

When navigating in-app, you can pass the full object:

context.go('/product/123', extra: product);

When arriving via deep link, state.extra is null, and the screen fetches the product using the ID from the URL. This pattern lets you optimize in-app navigation (no re-fetch) while still handling deep links correctly.

Error Handling

Custom Error Screen

final router = GoRouter(
  errorBuilder: (context, state) {
    return ErrorScreen(
      message: 'Page not found: ${state.uri.path}',
    );
  },
  routes: [
    // ...
  ],
);

Handling Invalid Parameters

Validate parameters in your route builders:

GoRoute(
  path: '/product/:id',
  builder: (context, state) {
    final idStr = state.pathParameters['id'];
    final id = int.tryParse(idStr ?? '');

    if (id == null) {
      return const NotFoundScreen();
    }

    return ProductScreen(id: id);
  },
),

Debugging

Enable diagnostic logging:

final router = GoRouter(
  debugLogDiagnostics: true,
  routes: [
    // ...
  ],
);

This prints to the console:

GoRouter: setting initial location /
GoRouter: Using MaterialApp configuration
GoRouter: going to /product/123
GoRouter: pushing /product/123

Testing Routes

Test your route configuration:

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('product route matches /product/:id', () {
    final match = router.configuration.findMatch('/product/123');
    expect(match.uri.path, '/product/123');
    expect(match.pathParameters['id'], '123');
  });

  testWidgets('deep link navigates to product screen', (tester) async {
    await tester.pumpWidget(
      MaterialApp.router(routerConfig: router),
    );

    router.go('/product/456');
    await tester.pumpAndSettle();

    expect(find.byType(ProductScreen), findsOneWidget);
  });
}

For the Tolinku SDK integration, see the Flutter SDK docs. For routing architecture, see Deep Link Routing Guide.

Get deep linking tips in your inbox

One email per week. No spam.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.