{"id":896,"date":"2026-04-24T13:00:00","date_gmt":"2026-04-24T18:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=896"},"modified":"2026-03-07T03:48:33","modified_gmt":"2026-03-07T08:48:33","slug":"flutter-go-router-deep-links","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/flutter-go-router-deep-links\/","title":{"rendered":"Flutter go_router Deep Links: Routing Guide"},"content":{"rendered":"\n<p>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.<\/p>\n\n\n\n<p>For the platform-level configuration (Associated Domains, intent filters), see <a href=\"https:\/\/tolinku.com\/blog\/flutter-deep-linking-setup\/\">Flutter Deep Linking: Complete Setup Tutorial<\/a>. For a cross-platform overview, see <a href=\"https:\/\/tolinku.com\/blog\/cross-platform-deep-linking-guide\/\">Cross-Platform Deep Linking Guide<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Basic Route Setup<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Install go_router<\/h3>\n\n\n\n<pre><code class=\"language-yaml\"># pubspec.yaml\ndependencies:\n  go_router: ^14.0.0\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Define Routes<\/h3>\n\n\n\n<pre><code class=\"language-dart\">final GoRouter router = GoRouter(\n  routes: [\n    GoRoute(\n      path: &#39;\/&#39;,\n      builder: (context, state) =&gt; const HomeScreen(),\n    ),\n    GoRoute(\n      path: &#39;\/product\/:id&#39;,\n      builder: (context, state) {\n        final id = state.pathParameters[&#39;id&#39;]!;\n        return ProductScreen(id: id);\n      },\n    ),\n    GoRoute(\n      path: &#39;\/category\/:slug&#39;,\n      builder: (context, state) {\n        final slug = state.pathParameters[&#39;slug&#39;]!;\n        return CategoryScreen(slug: slug);\n      },\n    ),\n    GoRoute(\n      path: &#39;\/invite\/:code&#39;,\n      builder: (context, state) {\n        final code = state.pathParameters[&#39;code&#39;]!;\n        return InviteScreen(code: code);\n      },\n    ),\n  ],\n  errorBuilder: (context, state) =&gt; const NotFoundScreen(),\n);\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Use with MaterialApp<\/h3>\n\n\n\n<pre><code class=\"language-dart\">class MyApp extends StatelessWidget {\n  @override\n  Widget build(BuildContext context) {\n    return MaterialApp.router(\n      routerConfig: router,\n    );\n  }\n}\n<\/code><\/pre>\n\n\n\n<p>When the OS delivers a deep link like <code>https:\/\/go.yourapp.com\/product\/456<\/code>, go_router strips the scheme and host, matches <code>\/product\/456<\/code> against your route patterns, and renders <code>ProductScreen(id: &quot;456&quot;)<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Nested Routes<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">ShellRoute for Persistent UI<\/h3>\n\n\n\n<p>Most apps have a bottom navigation bar that persists across screens. Use <code>ShellRoute<\/code> to keep the scaffold while deep links navigate the inner content:<\/p>\n\n\n\n<pre><code class=\"language-dart\">final router = GoRouter(\n  routes: [\n    ShellRoute(\n      builder: (context, state, child) {\n        return ScaffoldWithBottomNav(child: child);\n      },\n      routes: [\n        GoRoute(\n          path: &#39;\/&#39;,\n          builder: (context, state) =&gt; const HomeScreen(),\n          routes: [\n            GoRoute(\n              path: &#39;product\/:id&#39;,\n              builder: (context, state) {\n                final id = state.pathParameters[&#39;id&#39;]!;\n                return ProductScreen(id: id);\n              },\n            ),\n          ],\n        ),\n        GoRoute(\n          path: &#39;\/search&#39;,\n          builder: (context, state) =&gt; const SearchScreen(),\n          routes: [\n            GoRoute(\n              path: &#39;results&#39;,\n              builder: (context, state) {\n                final query = state.uri.queryParameters[&#39;q&#39;] ?? &#39;&#39;;\n                return SearchResultsScreen(query: query);\n              },\n            ),\n          ],\n        ),\n        GoRoute(\n          path: &#39;\/profile&#39;,\n          builder: (context, state) =&gt; const ProfileScreen(),\n        ),\n      ],\n    ),\n  ],\n);\n<\/code><\/pre>\n\n\n\n<p>Deep link to <code>\/product\/123<\/code> shows ProductScreen inside the ScaffoldWithBottomNav, with the Home tab selected. Deep link to <code>\/search\/results?q=shoes<\/code> shows SearchResultsScreen with the Search tab selected.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">StatefulShellRoute for Tab State Preservation<\/h3>\n\n\n\n<p>If you want each tab to maintain its own navigation state (so switching tabs doesn&#39;t reset the stack):<\/p>\n\n\n\n<pre><code class=\"language-dart\">final router = GoRouter(\n  routes: [\n    StatefulShellRoute.indexedStack(\n      builder: (context, state, navigationShell) {\n        return ScaffoldWithBottomNav(navigationShell: navigationShell);\n      },\n      branches: [\n        StatefulShellBranch(\n          routes: [\n            GoRoute(\n              path: &#39;\/&#39;,\n              builder: (context, state) =&gt; const HomeScreen(),\n              routes: [\n                GoRoute(\n                  path: &#39;product\/:id&#39;,\n                  builder: (context, state) {\n                    return ProductScreen(id: state.pathParameters[&#39;id&#39;]!);\n                  },\n                ),\n              ],\n            ),\n          ],\n        ),\n        StatefulShellBranch(\n          routes: [\n            GoRoute(\n              path: &#39;\/search&#39;,\n              builder: (context, state) =&gt; const SearchScreen(),\n            ),\n          ],\n        ),\n        StatefulShellBranch(\n          routes: [\n            GoRoute(\n              path: &#39;\/profile&#39;,\n              builder: (context, state) =&gt; const ProfileScreen(),\n            ),\n          ],\n        ),\n      ],\n    ),\n  ],\n);\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Query Parameters<\/h2>\n\n\n\n<p>Access query parameters through <code>state.uri.queryParameters<\/code>:<\/p>\n\n\n\n<pre><code class=\"language-dart\">GoRoute(\n  path: &#39;\/search&#39;,\n  builder: (context, state) {\n    final query = state.uri.queryParameters[&#39;q&#39;] ?? &#39;&#39;;\n    final sort = state.uri.queryParameters[&#39;sort&#39;] ?? &#39;relevance&#39;;\n    final page = int.tryParse(state.uri.queryParameters[&#39;page&#39;] ?? &#39;1&#39;) ?? 1;\n    return SearchScreen(query: query, sort: sort, page: page);\n  },\n),\n<\/code><\/pre>\n\n\n\n<p>URL: <code>https:\/\/go.yourapp.com\/search?q=shoes&amp;sort=price&amp;page=2<\/code><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Authentication Guards with Redirect<\/h2>\n\n\n\n<p>Deep links often target screens that require authentication. go_router&#39;s <code>redirect<\/code> function handles this:<\/p>\n\n\n\n<pre><code class=\"language-dart\">final router = GoRouter(\n  redirect: (context, state) {\n    final isLoggedIn = authService.isLoggedIn;\n    final isLoginRoute = state.matchedLocation == &#39;\/login&#39;;\n\n    \/\/ If not logged in and not already on login, redirect to login\n    if (isLoggedIn == false &amp;&amp; isLoginRoute == false) {\n      \/\/ Save the intended destination for after login\n      return &#39;\/login?redirect=${Uri.encodeComponent(state.uri.toString())}&#39;;\n    }\n\n    \/\/ If logged in and on login page, redirect to home\n    if (isLoggedIn &amp;&amp; isLoginRoute) {\n      return &#39;\/&#39;;\n    }\n\n    return null; \/\/ No redirect\n  },\n  routes: [\n    GoRoute(\n      path: &#39;\/login&#39;,\n      builder: (context, state) {\n        final redirect = state.uri.queryParameters[&#39;redirect&#39;];\n        return LoginScreen(redirectAfterLogin: redirect);\n      },\n    ),\n    GoRoute(\n      path: &#39;\/&#39;,\n      builder: (context, state) =&gt; const HomeScreen(),\n    ),\n    GoRoute(\n      path: &#39;\/product\/:id&#39;,\n      builder: (context, state) {\n        return ProductScreen(id: state.pathParameters[&#39;id&#39;]!);\n      },\n    ),\n  ],\n);\n<\/code><\/pre>\n\n\n\n<p>After login, navigate to the saved redirect URL:<\/p>\n\n\n\n<pre><code class=\"language-dart\">class LoginScreen extends StatelessWidget {\n  final String? redirectAfterLogin;\n\n  const LoginScreen({this.redirectAfterLogin});\n\n  void onLoginSuccess(BuildContext context) {\n    if (redirectAfterLogin != null) {\n      context.go(Uri.decodeComponent(redirectAfterLogin!));\n    } else {\n      context.go(&#39;\/&#39;);\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<p>This way, <code>https:\/\/go.yourapp.com\/product\/123<\/code> for an unauthenticated user shows the login screen, and after login, automatically navigates to the product.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Route-Level Redirects<\/h3>\n\n\n\n<p>For redirects that apply to specific routes (not globally):<\/p>\n\n\n\n<pre><code class=\"language-dart\">GoRoute(\n  path: &#39;\/admin&#39;,\n  redirect: (context, state) {\n    if (authService.isAdmin == false) {\n      return &#39;\/&#39;; \/\/ Non-admins redirected to home\n    }\n    return null;\n  },\n  builder: (context, state) =&gt; const AdminScreen(),\n),\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Legacy URLs<\/h2>\n\n\n\n<p>If you&#39;re migrating from another platform, your old links may use different URL patterns. Handle legacy paths with redirects:<\/p>\n\n\n\n<pre><code class=\"language-dart\">final router = GoRouter(\n  redirect: (context, state) {\n    final path = state.matchedLocation;\n\n    \/\/ Legacy: \/item\/123 \u2192 \/product\/123\n    if (path.startsWith(&#39;\/item\/&#39;)) {\n      return path.replaceFirst(&#39;\/item\/&#39;, &#39;\/product\/&#39;);\n    }\n\n    \/\/ Legacy: \/p?id=123 \u2192 \/product\/123\n    if (path == &#39;\/p&#39;) {\n      final id = state.uri.queryParameters[&#39;id&#39;];\n      if (id != null) return &#39;\/product\/$id&#39;;\n    }\n\n    return null;\n  },\n  routes: [\n    \/\/ Current routes...\n  ],\n);\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">TypeSafe Routes with Extra Data<\/h2>\n\n\n\n<p>go_router supports passing typed data alongside URL parameters:<\/p>\n\n\n\n<pre><code class=\"language-dart\">GoRoute(\n  path: &#39;\/product\/:id&#39;,\n  builder: (context, state) {\n    \/\/ URL parameter (always a string)\n    final id = state.pathParameters[&#39;id&#39;]!;\n\n    \/\/ Extra data passed programmatically (not from URL)\n    final product = state.extra as Product?;\n\n    return ProductScreen(id: id, preloadedProduct: product);\n  },\n),\n<\/code><\/pre>\n\n\n\n<p>When navigating in-app, you can pass the full object:<\/p>\n\n\n\n<pre><code class=\"language-dart\">context.go(&#39;\/product\/123&#39;, extra: product);\n<\/code><\/pre>\n\n\n\n<p>When arriving via deep link, <code>state.extra<\/code> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Error Handling<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Custom Error Screen<\/h3>\n\n\n\n<pre><code class=\"language-dart\">final router = GoRouter(\n  errorBuilder: (context, state) {\n    return ErrorScreen(\n      message: &#39;Page not found: ${state.uri.path}&#39;,\n    );\n  },\n  routes: [\n    \/\/ ...\n  ],\n);\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Handling Invalid Parameters<\/h3>\n\n\n\n<p>Validate parameters in your route builders:<\/p>\n\n\n\n<pre><code class=\"language-dart\">GoRoute(\n  path: &#39;\/product\/:id&#39;,\n  builder: (context, state) {\n    final idStr = state.pathParameters[&#39;id&#39;];\n    final id = int.tryParse(idStr ?? &#39;&#39;);\n\n    if (id == null) {\n      return const NotFoundScreen();\n    }\n\n    return ProductScreen(id: id);\n  },\n),\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Debugging<\/h2>\n\n\n\n<p>Enable diagnostic logging:<\/p>\n\n\n\n<pre><code class=\"language-dart\">final router = GoRouter(\n  debugLogDiagnostics: true,\n  routes: [\n    \/\/ ...\n  ],\n);\n<\/code><\/pre>\n\n\n\n<p>This prints to the console:<\/p>\n\n\n\n<pre><code>GoRouter: setting initial location \/\nGoRouter: Using MaterialApp configuration\nGoRouter: going to \/product\/123\nGoRouter: pushing \/product\/123\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Testing Routes<\/h2>\n\n\n\n<p>Test your route configuration:<\/p>\n\n\n\n<pre><code class=\"language-dart\">import &#39;package:flutter_test\/flutter_test.dart&#39;;\n\nvoid main() {\n  test(&#39;product route matches \/product\/:id&#39;, () {\n    final match = router.configuration.findMatch(&#39;\/product\/123&#39;);\n    expect(match.uri.path, &#39;\/product\/123&#39;);\n    expect(match.pathParameters[&#39;id&#39;], &#39;123&#39;);\n  });\n\n  testWidgets(&#39;deep link navigates to product screen&#39;, (tester) async {\n    await tester.pumpWidget(\n      MaterialApp.router(routerConfig: router),\n    );\n\n    router.go(&#39;\/product\/456&#39;);\n    await tester.pumpAndSettle();\n\n    expect(find.byType(ProductScreen), findsOneWidget);\n  });\n}\n<\/code><\/pre>\n\n\n\n<p>For the Tolinku SDK integration, see the <a href=\"https:\/\/tolinku.com\/docs\/developer\/sdks\/flutter\/\">Flutter SDK docs<\/a>. For routing architecture, see <a href=\"https:\/\/tolinku.com\/blog\/deep-link-routing-guide\/\">Deep Link Routing Guide<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Set up deep linking with Flutter go_router. Define route patterns, handle redirects, and manage authentication guards for deep link routes.<\/p>\n","protected":false},"author":2,"featured_media":895,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Flutter go_router Deep Links: Routing Guide","rank_math_description":"Set up deep linking with Flutter go_router. Define route patterns, handle redirects, and manage authentication guards for deep link routes.","rank_math_focus_keyword":"Flutter go_router deep links","rank_math_canonical_url":"","rank_math_facebook_title":"","rank_math_facebook_description":"","rank_math_facebook_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-flutter-go-router-deep-links.png","rank_math_facebook_image_id":"","rank_math_twitter_title":"","rank_math_twitter_description":"","rank_math_twitter_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-flutter-go-router-deep-links.png","footnotes":""},"categories":[15],"tags":[25,179,20,57,180,24,69,183],"class_list":["post-896","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-engineering","tag-android","tag-dart","tag-deep-linking","tag-flutter","tag-go-router","tag-ios","tag-mobile-development","tag-routing"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/896","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/comments?post=896"}],"version-history":[{"count":1,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/896\/revisions"}],"predecessor-version":[{"id":897,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/896\/revisions\/897"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/895"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=896"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=896"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=896"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}