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.