Skip to content
Blog

Deep Linking with go_router: From URL to Screen in Flutter

How Flutter deep linking works on iOS, Android, and the web, and how go_router turns incoming URLs into type-safe, parameterized routes.

Published on • October 18, 2026

AI Assistant

A deep link doesn’t just open your app — it lands the user on a specific screen inside it. An ad for a pair of sneakers should open your shopping app on that product’s page, not your home feed. Flutter supports deep linking on iOS, Android, and the web out of the box, and go_router — the Flutter team’s own declarative router, built on Navigation 2 — is the standard way to map those incoming URLs onto screens. Here’s how the pieces fit.

Per the Flutter deep linking docs, the framework handles incoming URLs itself — no plugin required. The behavior differs slightly by platform and by whether your app is already running:

  • Android, cold start: your app receives an initialRoute that already contains the deep link path (for example /products/42).
  • iOS, cold start: your app first gets initialRoute of /, and shortly after receives the deep link — which is why you must handle a late-arriving route, not just the initial one.
  • App already running (both platforms): the new URL is delivered while running, and with the Router API the navigator is configured with a new set of pages rather than blindly pushing.

On the web, there’s nothing to configure at the OS level — route paths are handled the same way. By default Flutter web reads the deep link from the URL fragment (/#/path/to/app/screen), which you can change by configuring the URL strategy for path-based URLs.

Two important notes from the docs:

  • Named routes are no longer recommended for most applications. A declarative router is the sanctioned path forward.
  • If you previously used a plugin to handle deep links, opt out of Flutter’s built-in handler: set FlutterDeepLinkingEnabled to false in Info.plist (iOS) or flutter_deeplinking_enabled to false in AndroidManifest.xml (Android). Otherwise you’ll fight two handlers for the same URL.

Why go_router

go_router (currently at 18.x, published by the Flutter team and a Flutter Favorite) wraps Navigation 2 in a URL-first API. Its feature set maps directly onto deep-linking needs:

  • Path and query parameter templates — '/user/:id' parses real values out of incoming URLs.
  • Redirection — bounce unauthenticated users to /login from any deep link.
  • Multiple navigators — ShellRoute keeps a bottom nav bar visible while inner screens change.
  • Type-safe routes — a newer option that generates route classes so navigation calls are checked at compile time.
  • Backwards compatibility with the Navigator API you already know.

The setup

Add the dependency and hand your router config to MaterialApp.router:

dependencies:
  flutter:
    sdk: flutter
  go_router: ^18.1.0

Define routes as URL patterns; incoming deep links flow through the same table as in-app navigation, so there’s exactly one source of truth for “what screen does this path show”:

final router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
      routes: [
        GoRoute(
          path: 'products/:id',
          name: 'product',
          builder: (context, state) {
            final id = int.parse(state.pathParameters['id']!);
            final ref = state.uri.queryParameters['ref']; // ?ref=ad
            return ProductPage(productId: id, referral: ref);
          },
        ),
      ],
    ),
    GoRoute(
      path: '/settings',
      builder: (context, state) => const SettingsPage(),
    ),
  ],
);

// in your app root:
MaterialApp.router(routerConfig: router);

Now https://example.com/products/42?ref=ad — from an email, an ad, or a push notification — opens your app directly on product 42 with the referral code attached. In-app navigation uses the identical route table:

context.go('/products/42?ref=email');   // replace the stack (URL-style)
context.push('/products/42');           // push, so back button pops
context.goNamed('product', pathParameters: {'id': '42'});

The distinction that trips people up: go replaces the entire location (the browser URL updates and the stack reflects the new path — right for deep-link-like navigation), while push adds a route on top (right for drill-in flows where back should return to the list).

Deep links are untrusted input from outside your app, and the most common requirement is gating them behind auth. go_router’s declarative redirect callback handles it once, for every entry point:

final router = GoRouter(
  routes: [...],
  redirect: (context, state) {
    final loggedIn = session.isLoggedIn;
    final goingToLogin = state.matchedLocation == '/login';
    if (!loggedIn && !goingToLogin) return '/login?from=${state.uri}';
    if (loggedIn && goingToLogin) return '/';
    return null; // no redirect
  },
);

Returning null means “let the navigation proceed.” Because the redirect runs for deep links too, a cold-start link to /settings from a logged-out user lands on /login — and your login page can read the from parameter to continue to /settings after sign-in.

Platform site configuration

The Dart side is only half the job: iOS and Android must be told your domain belongs to your app.

  • Android needs intent filters with autoVerify in AndroidManifest.xml plus an assetlinks.json file hosted on your domain. See the app links cookbook.
  • iOS needs Universal Links: the Associated Domains entitlement on your app plus an apple-app-site-association file on your domain. See the universal links cookbook.
  • Web needs only your URL-strategy choice.

You can verify the OS-level wiring with the Validate deep links tool in DevTools, which exercises incoming links against your running app — far faster than emailing yourself links during development.

Because routes are just URL strings, deep-link behavior is testable without a device. Pump your app, then drive the router:

testWidgets('deep link to product shows detail', (tester) async {
  await tester.pumpWidget(MyApp(initialLocation: '/products/42?ref=ad'));
  expect(find.text('Product 42'), findsOneWidget);
});

Give your MaterialApp.router an injectable initialLocation (or construct a fresh GoRouter per test) and you can assert cold-start deep-link behavior — including your redirect logic — in plain widget tests.

The shape of the whole thing

Deep linking in Flutter is three layers: the OS hands the URL to the app (configured via intent filters / associated domains), the framework delivers it to your router (built in, no plugin), and go_router maps the path — parameters, guards, and all — onto a widget. Keep one route table, treat redirect as your gatekeeper, prefer go over push for externally-originating navigation, and you’ll get deep links that behave the same whether the app was cold, warm, or mid-session.

Sources